@opengeni/api-router 0.30.3 → 2.2.0-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/api-websocket.d.ts +18 -0
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +6 -0
- package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
- package/dist/chunk-3TP54PPX.js.map +1 -0
- package/dist/codemode.d.ts +8 -1
- package/dist/company-brain-okf.d.ts +24 -0
- package/dist/connected-machine-computer-access.d.ts +11 -0
- package/dist/connection-authority-owner.d.ts +59 -0
- package/dist/connection-ownership.d.ts +47 -0
- package/dist/controller-data-plane.d.ts +16 -0
- package/dist/editable-artifact-websocket.d.ts +5 -15
- package/dist/editable-artifact-workspace-files.d.ts +1 -1
- package/dist/http/api-error.d.ts +10 -0
- package/dist/http/cors.d.ts +5 -0
- package/dist/http/interaction-control-error.d.ts +13 -0
- package/dist/http/sse.d.ts +2 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +326 -82
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +17 -0
- package/dist/integrations/fiken.d.ts +7 -2
- package/dist/integrations/google-drive.d.ts +18 -0
- package/dist/integrations/oauth-client.d.ts +22 -3
- package/dist/integrations/oauth-profiles.d.ts +195 -0
- package/dist/integrations/personal-github-repositories.d.ts +45 -0
- package/dist/integrations/personal-github.d.ts +40 -0
- package/dist/integrations/pr-review-provider.d.ts +22 -0
- package/dist/integrations/provider-oauth.d.ts +8 -0
- package/dist/integrations/slack-app-home.d.ts +24 -0
- package/dist/integrations/slack-bot.d.ts +157 -9
- package/dist/integrations/slack-interactions.d.ts +39 -5
- package/dist/integrations/slack-routing.d.ts +120 -0
- package/dist/integrations/social-oauth.d.ts +5 -0
- package/dist/interaction-frame-proxy.d.ts +68 -0
- package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
- package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
- package/dist/mcp/documents.d.ts +1 -0
- package/dist/mcp/remember.d.ts +28 -0
- package/dist/mcp/request-abort.d.ts +19 -0
- package/dist/mcp/scheduled-task-view.d.ts +4 -4
- package/dist/mcp/server.d.ts +15 -2
- package/dist/mcp/session-view.d.ts +4 -0
- package/dist/mcp/session-wait.d.ts +137 -0
- package/dist/routes/automations.d.ts +4 -0
- package/dist/routes/billing.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +4 -1
- package/dist/routes/company-brain.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +2 -1
- package/dist/routes/connection-authorities.d.ts +3 -0
- package/dist/routes/personal-github-git-broker.d.ts +29 -0
- package/dist/routes/personal-github.d.ts +3 -0
- package/dist/routes/pr-review.d.ts +3 -0
- package/dist/routes/sessions.d.ts +1 -0
- package/dist/routes/user-resource-authorities.d.ts +3 -0
- package/dist/routes/workspace-capture.d.ts +10 -2
- package/dist/routes/workspace-learning.d.ts +3 -0
- package/dist/routes/workspaces.d.ts +19 -0
- package/dist/sandbox/channel-a.d.ts +11 -1
- package/dist/sandbox/connection-authority.d.ts +3 -0
- package/dist/sandbox/enrollment.d.ts +2 -0
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/metrics-ingestion.d.ts +10 -20
- package/dist/sandbox/viewer.d.ts +24 -1
- package/dist/sandbox-file-artifacts.d.ts +11 -0
- package/dist/scheduled-task-deletion.d.ts +14 -0
- package/dist/slack-reaction-files.d.ts +1 -1
- package/dist/temporal-schedule-cleanup.d.ts +1 -0
- package/package.json +20 -18
- package/src/api-websocket.ts +23 -0
- package/src/app.ts +161 -23
- package/src/auth/managed-auth.ts +25 -3
- package/src/codemode.ts +53 -23
- package/src/codex-realtime.ts +8 -2
- package/src/company-brain-okf.ts +340 -0
- package/src/connected-machine-computer-access.ts +33 -0
- package/src/connection-authority-owner.ts +61 -0
- package/src/connection-ownership.ts +180 -0
- package/src/controller-data-plane.ts +47 -0
- package/src/editable-artifact-native-kernel.ts +26 -15
- package/src/editable-artifact-office-import.ts +28 -3
- package/src/editable-artifact-production.ts +13 -7
- package/src/editable-artifact-websocket.ts +11 -18
- package/src/editable-artifact-workspace-files.ts +31 -6
- package/src/http/api-error.ts +28 -0
- package/src/http/auth.ts +4 -0
- package/src/http/cors.ts +35 -0
- package/src/http/interaction-control-error.ts +164 -0
- package/src/http/sse.ts +112 -66
- package/src/index.ts +46 -6
- package/src/integrations/atlassian.ts +146 -35
- package/src/integrations/fiken.ts +102 -22
- package/src/integrations/google-drive.ts +301 -92
- package/src/integrations/oauth-client.ts +272 -143
- package/src/integrations/oauth-profiles.ts +477 -0
- package/src/integrations/personal-github-repositories.ts +445 -0
- package/src/integrations/personal-github.ts +705 -0
- package/src/integrations/pr-review-provider.ts +246 -0
- package/src/integrations/provider-oauth.ts +121 -5
- package/src/integrations/slack-app-home.ts +300 -0
- package/src/integrations/slack-bot.ts +342 -57
- package/src/integrations/slack-interactions.ts +1898 -259
- package/src/integrations/slack-routing.ts +337 -0
- package/src/integrations/social-oauth.ts +25 -0
- package/src/interaction-frame-proxy.ts +409 -0
- package/src/mcp/company-brain-governed-writes.ts +262 -0
- package/src/mcp/company-profile-agent-admin.ts +205 -0
- package/src/mcp/documents.ts +37 -6
- package/src/mcp/files.ts +16 -1
- package/src/mcp/remember.ts +181 -0
- package/src/mcp/request-abort.ts +44 -0
- package/src/mcp/scheduled-task-view.ts +1 -0
- package/src/mcp/server.ts +719 -369
- package/src/mcp/session-view.ts +54 -0
- package/src/mcp/session-wait.ts +554 -0
- package/src/model-catalog.ts +20 -12
- package/src/routes/api-integrations.ts +4 -0
- package/src/routes/api-keys.ts +1 -0
- package/src/routes/automations.ts +534 -0
- package/src/routes/billing.ts +57 -1
- package/src/routes/browser-sessions.ts +329 -79
- package/src/routes/channels.ts +17 -1
- package/src/routes/codex.ts +50 -0
- package/src/routes/company-brain.ts +367 -0
- package/src/routes/company-profile.ts +1 -1
- package/src/routes/computer-sessions.ts +320 -93
- package/src/routes/connection-authorities.ts +139 -0
- package/src/routes/connections.ts +144 -34
- package/src/routes/documents.ts +353 -4
- package/src/routes/editable-artifacts.ts +11 -3
- package/src/routes/enrollments.ts +116 -26
- package/src/routes/environments.ts +127 -55
- package/src/routes/files.ts +72 -11
- package/src/routes/install.ts +82 -28
- package/src/routes/integration-facets.ts +29 -0
- package/src/routes/interaction-resources.ts +20 -2
- package/src/routes/machines.ts +275 -13
- package/src/routes/organization-memberships.ts +717 -28
- package/src/routes/packs.ts +4 -3
- package/src/routes/personal-github-git-broker.ts +785 -0
- package/src/routes/personal-github.ts +319 -0
- package/src/routes/pr-review.ts +531 -0
- package/src/routes/rigs.ts +104 -39
- package/src/routes/scheduled-tasks.ts +19 -21
- package/src/routes/sessions.ts +775 -38
- package/src/routes/social.ts +14 -2
- package/src/routes/supergrok.ts +23 -4
- package/src/routes/transcription-recordings.ts +8 -2
- package/src/routes/user-resource-authorities.ts +138 -0
- package/src/routes/workspace-artifacts.ts +4 -1
- package/src/routes/workspace-capture.ts +21 -0
- package/src/routes/workspace-learning.ts +228 -0
- package/src/routes/workspaces.ts +59 -6
- package/src/sandbox/auth-callout.ts +38 -12
- package/src/sandbox/channel-a.ts +242 -19
- package/src/sandbox/connection-authority.ts +3 -0
- package/src/sandbox/enrollment.ts +15 -3
- package/src/sandbox/machines.ts +186 -64
- package/src/sandbox/metrics-ingestion.ts +220 -58
- package/src/sandbox/viewer.ts +243 -19
- package/src/sandbox-file-artifacts.ts +329 -0
- package/src/scheduled-task-deletion.ts +127 -0
- package/src/slack-reaction-files.ts +16 -5
- package/src/temporal-schedule-cleanup.ts +13 -0
- package/dist/chunk-WCXHI3FF.js.map +0 -1
package/src/routes/social.ts
CHANGED
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
import type { Hono } from "hono";
|
|
16
16
|
import { HTTPException } from "hono/http-exception";
|
|
17
17
|
import { z } from "zod";
|
|
18
|
-
import { requireAccessGrant } from "@opengeni/core";
|
|
18
|
+
import { requireAccessGrant, requireAccessGrantAuthorization } from "@opengeni/core";
|
|
19
|
+
import {
|
|
20
|
+
assertPersonalConnectionOwnerPrincipal,
|
|
21
|
+
isPersonalConnectionOwnerPrincipal,
|
|
22
|
+
} from "../connection-ownership";
|
|
19
23
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
20
24
|
import { boundedLimit } from "../http/common";
|
|
21
25
|
import { completeSocialOAuthCallback, startSocialOAuth } from "../integrations/social-oauth";
|
|
@@ -99,18 +103,26 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
99
103
|
});
|
|
100
104
|
}
|
|
101
105
|
const payload = parsed.data;
|
|
102
|
-
const
|
|
106
|
+
const access = await requireAccessGrantAuthorization(
|
|
103
107
|
c,
|
|
104
108
|
deps,
|
|
105
109
|
workspaceId,
|
|
106
110
|
payload.ownership === "workspace" ? "workspace:admin" : "workspace:read",
|
|
107
111
|
);
|
|
112
|
+
// The contract already defaults this request to workspace ownership; an
|
|
113
|
+
// explicit personal choice still needs a human who can own it.
|
|
114
|
+
const personalOwnershipAllowed = isPersonalConnectionOwnerPrincipal(access);
|
|
115
|
+
if (payload.ownership === "personal") {
|
|
116
|
+
assertPersonalConnectionOwnerPrincipal(access);
|
|
117
|
+
}
|
|
118
|
+
const grant = access.grant;
|
|
108
119
|
const result = await startSocialOAuth(
|
|
109
120
|
{ db, settings, observability },
|
|
110
121
|
{
|
|
111
122
|
accountId: grant.accountId,
|
|
112
123
|
workspaceId,
|
|
113
124
|
subjectId: grant.subjectId,
|
|
125
|
+
personalOwnershipAllowed,
|
|
114
126
|
requestUrl: c.req.url,
|
|
115
127
|
payload,
|
|
116
128
|
},
|
package/src/routes/supergrok.ts
CHANGED
|
@@ -31,11 +31,11 @@ import {
|
|
|
31
31
|
XAI_CLIENT_VERSION,
|
|
32
32
|
XaiSubscriptionError,
|
|
33
33
|
fetchXaiSubscriptionModels,
|
|
34
|
-
fetchXaiVerifiedIdentity,
|
|
35
34
|
pollXaiDeviceCode,
|
|
36
35
|
refreshXaiToken,
|
|
37
36
|
requestXaiDeviceCode,
|
|
38
37
|
xaiAccessTokenExpiry,
|
|
38
|
+
xaiIdentityFromDeviceTokens,
|
|
39
39
|
type XaiFetch,
|
|
40
40
|
type XaiProxyAuthContext,
|
|
41
41
|
} from "@opengeni/xai-subscription";
|
|
@@ -392,6 +392,7 @@ export function registerSuperGrokRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
392
392
|
if (Math.floor(Date.now() / 1_000) >= state.expiresAt) {
|
|
393
393
|
return c.json({ status: "expired" as const });
|
|
394
394
|
}
|
|
395
|
+
let phase = "device_token_exchange";
|
|
395
396
|
try {
|
|
396
397
|
const poll = await pollXaiDeviceCode(
|
|
397
398
|
{
|
|
@@ -401,9 +402,9 @@ export function registerSuperGrokRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
401
402
|
{ fetch: (deps.xaiFetch ?? fetch) as XaiFetch },
|
|
402
403
|
);
|
|
403
404
|
if (poll.status !== "authorized") return c.json(poll);
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
405
|
+
phase = "token_identity";
|
|
406
|
+
const identity = xaiIdentityFromDeviceTokens(poll.tokens);
|
|
407
|
+
phase = "credential_persist";
|
|
407
408
|
const encryptionKey = environmentsEncryptionKeyBytes(deps.settings);
|
|
408
409
|
if (!encryptionKey) {
|
|
409
410
|
throw new HTTPException(500, {
|
|
@@ -458,6 +459,24 @@ export function registerSuperGrokRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
458
459
|
email: identity.email,
|
|
459
460
|
});
|
|
460
461
|
} catch (error) {
|
|
462
|
+
deps.observability?.warn("SuperGrok connection operation failed", {
|
|
463
|
+
provider: "xai",
|
|
464
|
+
providerApi: "oauth",
|
|
465
|
+
op: phase,
|
|
466
|
+
outcome: "failed",
|
|
467
|
+
reason:
|
|
468
|
+
error instanceof XaiSubscriptionError
|
|
469
|
+
? error.kind
|
|
470
|
+
: error instanceof HTTPException
|
|
471
|
+
? "http_exception"
|
|
472
|
+
: "unexpected",
|
|
473
|
+
status:
|
|
474
|
+
error instanceof XaiSubscriptionError
|
|
475
|
+
? error.status
|
|
476
|
+
: error instanceof HTTPException
|
|
477
|
+
? error.status
|
|
478
|
+
: undefined,
|
|
479
|
+
});
|
|
461
480
|
throw xaiHttpError(error, "SuperGrok device login failed");
|
|
462
481
|
}
|
|
463
482
|
});
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
TranscriptionServiceError,
|
|
18
18
|
type ApiRouteDeps,
|
|
19
19
|
} from "@opengeni/core";
|
|
20
|
+
import { retryWhileMissing } from "@opengeni/storage";
|
|
20
21
|
import {
|
|
21
22
|
claimNextTranscriptionRecordingSegment,
|
|
22
23
|
claimTranscriptionRecordingAssembly,
|
|
@@ -394,7 +395,10 @@ export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDe
|
|
|
394
395
|
);
|
|
395
396
|
}
|
|
396
397
|
segmentNumber = claim.segment.segmentNumber;
|
|
397
|
-
const
|
|
398
|
+
const segmentKey = claim.segment.objectKey;
|
|
399
|
+
const stored = deps.objectStorage
|
|
400
|
+
? await retryWhileMissing(async () => deps.objectStorage!.getObjectBytes(segmentKey))
|
|
401
|
+
: null;
|
|
398
402
|
if (!stored) {
|
|
399
403
|
throw new RecordingProcessingError("Provider segment is missing", "invalid_audio", false);
|
|
400
404
|
}
|
|
@@ -578,7 +582,9 @@ async function* verifiedChunkBytes(
|
|
|
578
582
|
}
|
|
579
583
|
let stored: Awaited<ReturnType<NonNullable<ApiRouteDeps["objectStorage"]>["getObjectBytes"]>>;
|
|
580
584
|
try {
|
|
581
|
-
stored = await
|
|
585
|
+
stored = await retryWhileMissing(async () =>
|
|
586
|
+
deps.objectStorage!.getObjectBytes(chunk.objectKey),
|
|
587
|
+
);
|
|
582
588
|
} catch {
|
|
583
589
|
throw new RecordingProcessingError("Chunk download failed", "network", true);
|
|
584
590
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IssueUserResourceGrantRequest,
|
|
3
|
+
ListUserResourceAuthoritiesQuery,
|
|
4
|
+
ListUserResourceAuthoritiesResponse,
|
|
5
|
+
RevokeUserResourceGrantQuery,
|
|
6
|
+
UserResourceGrantMutationResponse,
|
|
7
|
+
UserResourceGrantRevocationResponse,
|
|
8
|
+
} from "@opengeni/contracts";
|
|
9
|
+
import {
|
|
10
|
+
issueManagedHumanUserResourceGrant,
|
|
11
|
+
listManagedHumanUserResourceAuthorities,
|
|
12
|
+
requireAccessGrantAuthorization,
|
|
13
|
+
revokeManagedHumanUserResourceGrant,
|
|
14
|
+
SessionAuthorizationDeniedError,
|
|
15
|
+
SessionAuthorizationUnavailableError,
|
|
16
|
+
SessionTenancyManagedHumanRequiredError,
|
|
17
|
+
type ApiRouteDeps,
|
|
18
|
+
} from "@opengeni/core";
|
|
19
|
+
import { nestedPostgresSqlState, SessionTenancyNotActivatedError } from "@opengeni/db";
|
|
20
|
+
import type { Context, Hono } from "hono";
|
|
21
|
+
import { HTTPException } from "hono/http-exception";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
|
|
24
|
+
async function body<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
|
|
25
|
+
const parsed = schema.safeParse(await context.req.json().catch(() => null));
|
|
26
|
+
if (!parsed.success) throw new HTTPException(422, { message: "invalid user-resource request" });
|
|
27
|
+
return parsed.data;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function lifecycleError(error: unknown): never {
|
|
31
|
+
if (
|
|
32
|
+
error instanceof SessionAuthorizationDeniedError ||
|
|
33
|
+
error instanceof SessionTenancyManagedHumanRequiredError ||
|
|
34
|
+
error instanceof SessionTenancyNotActivatedError
|
|
35
|
+
) {
|
|
36
|
+
throw new HTTPException(403, { message: "user-resource authority denied" });
|
|
37
|
+
}
|
|
38
|
+
if (error instanceof SessionAuthorizationUnavailableError) {
|
|
39
|
+
throw new HTTPException(503, { message: "session authorization unavailable" });
|
|
40
|
+
}
|
|
41
|
+
if (nestedPostgresSqlState(error) === "42501") {
|
|
42
|
+
throw new HTTPException(403, { message: "user-resource authority denied" });
|
|
43
|
+
}
|
|
44
|
+
if (nestedPostgresSqlState(error) === "22023") {
|
|
45
|
+
throw new HTTPException(422, { message: "invalid user-resource request" });
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function registerUserResourceAuthorityRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
51
|
+
const base = "/v1/workspaces/:workspaceId/user-resource-authorities";
|
|
52
|
+
app.get(base, async (context) => {
|
|
53
|
+
const query = ListUserResourceAuthoritiesQuery.safeParse({
|
|
54
|
+
scope: context.req.query("scope"),
|
|
55
|
+
resourceKind: context.req.query("resourceKind"),
|
|
56
|
+
cursor: context.req.query("cursor"),
|
|
57
|
+
limit: context.req.query("limit"),
|
|
58
|
+
});
|
|
59
|
+
if (!query.success) throw new HTTPException(422, { message: "explicit scope=user required" });
|
|
60
|
+
const workspaceId = context.req.param("workspaceId");
|
|
61
|
+
const access = await requireAccessGrantAuthorization(
|
|
62
|
+
context,
|
|
63
|
+
deps,
|
|
64
|
+
workspaceId,
|
|
65
|
+
"workspace:read",
|
|
66
|
+
);
|
|
67
|
+
try {
|
|
68
|
+
const page = await listManagedHumanUserResourceAuthorities(
|
|
69
|
+
deps,
|
|
70
|
+
access,
|
|
71
|
+
workspaceId,
|
|
72
|
+
query.data,
|
|
73
|
+
);
|
|
74
|
+
return context.json(
|
|
75
|
+
ListUserResourceAuthoritiesResponse.parse({
|
|
76
|
+
scope: "user",
|
|
77
|
+
...page,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return lifecycleError(error);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
app.post(`${base}/:authorityId/grants`, async (context) => {
|
|
86
|
+
const workspaceId = context.req.param("workspaceId");
|
|
87
|
+
const authorityId = z.string().uuid().safeParse(context.req.param("authorityId"));
|
|
88
|
+
if (!authorityId.success) throw new HTTPException(422, { message: "invalid authority id" });
|
|
89
|
+
const request = await body(context, IssueUserResourceGrantRequest);
|
|
90
|
+
const access = await requireAccessGrantAuthorization(
|
|
91
|
+
context,
|
|
92
|
+
deps,
|
|
93
|
+
workspaceId,
|
|
94
|
+
"workspace:read",
|
|
95
|
+
);
|
|
96
|
+
try {
|
|
97
|
+
return context.json(
|
|
98
|
+
UserResourceGrantMutationResponse.parse({
|
|
99
|
+
scope: "user",
|
|
100
|
+
grant: await issueManagedHumanUserResourceGrant(
|
|
101
|
+
deps,
|
|
102
|
+
access,
|
|
103
|
+
workspaceId,
|
|
104
|
+
authorityId.data,
|
|
105
|
+
request,
|
|
106
|
+
"http",
|
|
107
|
+
),
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return lifecycleError(error);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
app.delete(`${base}/grants/:grantId`, async (context) => {
|
|
116
|
+
const query = RevokeUserResourceGrantQuery.safeParse({ scope: context.req.query("scope") });
|
|
117
|
+
if (!query.success) throw new HTTPException(422, { message: "explicit scope=user required" });
|
|
118
|
+
const grantId = z.string().uuid().safeParse(context.req.param("grantId"));
|
|
119
|
+
if (!grantId.success) throw new HTTPException(422, { message: "invalid grant id" });
|
|
120
|
+
const workspaceId = context.req.param("workspaceId");
|
|
121
|
+
const access = await requireAccessGrantAuthorization(
|
|
122
|
+
context,
|
|
123
|
+
deps,
|
|
124
|
+
workspaceId,
|
|
125
|
+
"workspace:read",
|
|
126
|
+
);
|
|
127
|
+
try {
|
|
128
|
+
return context.json(
|
|
129
|
+
UserResourceGrantRevocationResponse.parse({
|
|
130
|
+
scope: "user",
|
|
131
|
+
grant: await revokeManagedHumanUserResourceGrant(deps, access, workspaceId, grantId.data),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return lifecycleError(error);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
normalizeWorkspaceArtifactSlug,
|
|
12
12
|
} from "@opengeni/contracts";
|
|
13
13
|
import { requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
|
|
14
|
+
import { retryWhileMissing } from "@opengeni/storage";
|
|
14
15
|
import {
|
|
15
16
|
createWorkspaceArtifact,
|
|
16
17
|
getWorkspaceArtifact,
|
|
@@ -197,7 +198,9 @@ export function registerWorkspaceArtifactRoutes(app: Hono, deps: ApiRouteDeps):
|
|
|
197
198
|
artifactId(context),
|
|
198
199
|
parsedVersion,
|
|
199
200
|
);
|
|
200
|
-
const object = await
|
|
201
|
+
const object = await retryWhileMissing(async () =>
|
|
202
|
+
deps.objectStorage!.getObjectBytes(ref.contentKey),
|
|
203
|
+
);
|
|
201
204
|
if (!object) throw new HTTPException(503, { message: "Artifact content is unavailable" });
|
|
202
205
|
const actualHash = createHash("sha256").update(object.bytes).digest("hex");
|
|
203
206
|
if (actualHash !== ref.version.contentSha256) {
|
|
@@ -215,10 +215,20 @@ async function loadManifest(
|
|
|
215
215
|
* manifest blob has been GC'd (all graceful cold-fallback states — never errors).
|
|
216
216
|
* Inline manifest for ≤2MB, signed URL above.
|
|
217
217
|
*/
|
|
218
|
+
/** Awaited before a signed capture URL is returned to the caller: the route
|
|
219
|
+
* records the metadata-only issuance audit fact. A throwing recorder fails
|
|
220
|
+
* the serve closed - no recorded fact, no bearer capability. */
|
|
221
|
+
export type CaptureSignedUrlIssuanceRecorder = (fact: {
|
|
222
|
+
kind: "capture_manifest" | "capture_file";
|
|
223
|
+
revision: number;
|
|
224
|
+
expiresAt: string;
|
|
225
|
+
}) => Promise<void>;
|
|
226
|
+
|
|
218
227
|
export async function serveWorkspaceCapture(
|
|
219
228
|
row: WorkspaceCaptureRow | null,
|
|
220
229
|
storage: CaptureStoragePort,
|
|
221
230
|
cache?: WorkspaceCaptureManifestCache,
|
|
231
|
+
onSignedUrlIssued?: CaptureSignedUrlIssuanceRecorder,
|
|
222
232
|
): Promise<GetWorkspaceCaptureResponse> {
|
|
223
233
|
if (!row) {
|
|
224
234
|
return { available: false };
|
|
@@ -274,6 +284,11 @@ export async function serveWorkspaceCapture(
|
|
|
274
284
|
key: row.manifestKey,
|
|
275
285
|
expiresInSeconds: CAPTURE_SIGNED_URL_TTL_SECONDS,
|
|
276
286
|
});
|
|
287
|
+
await onSignedUrlIssued?.({
|
|
288
|
+
kind: "capture_manifest",
|
|
289
|
+
revision: row.revision,
|
|
290
|
+
expiresAt: signed.expiresAt.toISOString(),
|
|
291
|
+
});
|
|
277
292
|
return GetWorkspaceCaptureResponse.parse({
|
|
278
293
|
...meta,
|
|
279
294
|
manifest: null,
|
|
@@ -293,6 +308,7 @@ export async function serveWorkspaceCaptureFile(
|
|
|
293
308
|
row: WorkspaceCaptureRow | null,
|
|
294
309
|
path: string,
|
|
295
310
|
storage: CaptureStoragePort,
|
|
311
|
+
onSignedUrlIssued?: CaptureSignedUrlIssuanceRecorder,
|
|
296
312
|
): Promise<GetWorkspaceCaptureFileResponse> {
|
|
297
313
|
const loaded = row ? await loadManifest(row, storage) : null;
|
|
298
314
|
if (!loaded) {
|
|
@@ -347,6 +363,11 @@ export async function serveWorkspaceCaptureFile(
|
|
|
347
363
|
key: file.contentRef,
|
|
348
364
|
expiresInSeconds: CAPTURE_SIGNED_URL_TTL_SECONDS,
|
|
349
365
|
});
|
|
366
|
+
await onSignedUrlIssued?.({
|
|
367
|
+
kind: "capture_file",
|
|
368
|
+
revision: row!.revision,
|
|
369
|
+
expiresAt: signed.expiresAt.toISOString(),
|
|
370
|
+
});
|
|
350
371
|
return GetWorkspaceCaptureFileResponse.parse({
|
|
351
372
|
...base,
|
|
352
373
|
encoding: null,
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
ActivateWorkspaceLearningPolicyRevisionRequest,
|
|
4
|
+
CreateWorkspaceLearningPolicyRevisionRequest,
|
|
5
|
+
RollbackWorkspaceLearningPolicyRevisionRequest,
|
|
6
|
+
UndoGovernedLearningActivationHttpRequest,
|
|
7
|
+
WorkspaceLearningHistoryResponse,
|
|
8
|
+
WorkspaceLearningPolicyHistoryQuery,
|
|
9
|
+
WorkspaceLearningPolicyMutationResponse,
|
|
10
|
+
WorkspaceLearningPolicyRevision,
|
|
11
|
+
} from "@opengeni/contracts";
|
|
12
|
+
import {
|
|
13
|
+
publishGovernedLearningEventToSlack,
|
|
14
|
+
requireAccessGrant,
|
|
15
|
+
type ApiRouteDeps,
|
|
16
|
+
} from "@opengeni/core";
|
|
17
|
+
import {
|
|
18
|
+
activateWorkspaceLearningPolicyRevision,
|
|
19
|
+
createWorkspaceLearningPolicyRevision,
|
|
20
|
+
GovernedLearningActivationAuthorityError,
|
|
21
|
+
GovernedLearningActivationConflictError,
|
|
22
|
+
GovernedLearningActivationInvalidOperationError,
|
|
23
|
+
GovernedLearningEvaluationAuthorityError,
|
|
24
|
+
listGovernedLearningActivationHistory,
|
|
25
|
+
listGovernedLearningDecisionReceipts,
|
|
26
|
+
listWorkspaceLearningPolicyHistory,
|
|
27
|
+
rollbackWorkspaceLearningPolicyRevision,
|
|
28
|
+
undoGovernedLearningActivation,
|
|
29
|
+
WorkspaceLearningPolicyAuthorityError,
|
|
30
|
+
WorkspaceLearningPolicyConflictError,
|
|
31
|
+
WorkspaceLearningPolicyInvalidOperationError,
|
|
32
|
+
WorkspaceLearningPolicyNotFoundError,
|
|
33
|
+
WorkspaceLearningPolicyOperationReuseError,
|
|
34
|
+
} from "@opengeni/db";
|
|
35
|
+
import type { Context, Hono } from "hono";
|
|
36
|
+
import { HTTPException } from "hono/http-exception";
|
|
37
|
+
import { z } from "zod";
|
|
38
|
+
|
|
39
|
+
async function parseBody<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
|
|
40
|
+
const parsed = schema.safeParse(await context.req.json().catch(() => null));
|
|
41
|
+
if (!parsed.success) {
|
|
42
|
+
throw new HTTPException(422, { message: "Invalid workspace learning request" });
|
|
43
|
+
}
|
|
44
|
+
return parsed.data;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function learningError(error: unknown): never {
|
|
48
|
+
if (
|
|
49
|
+
error instanceof WorkspaceLearningPolicyAuthorityError ||
|
|
50
|
+
error instanceof GovernedLearningEvaluationAuthorityError ||
|
|
51
|
+
error instanceof GovernedLearningActivationAuthorityError
|
|
52
|
+
) {
|
|
53
|
+
throw new HTTPException(403, { message: error.message });
|
|
54
|
+
}
|
|
55
|
+
if (error instanceof WorkspaceLearningPolicyNotFoundError) {
|
|
56
|
+
throw new HTTPException(404, { message: error.message });
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
error instanceof WorkspaceLearningPolicyConflictError ||
|
|
60
|
+
error instanceof WorkspaceLearningPolicyOperationReuseError ||
|
|
61
|
+
error instanceof GovernedLearningActivationConflictError
|
|
62
|
+
) {
|
|
63
|
+
throw new HTTPException(409, { message: error.message });
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
error instanceof WorkspaceLearningPolicyInvalidOperationError ||
|
|
67
|
+
error instanceof GovernedLearningActivationInvalidOperationError
|
|
68
|
+
) {
|
|
69
|
+
throw new HTTPException(422, { message: error.message });
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function registerWorkspaceLearningRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
75
|
+
const base = "/v1/workspaces/:workspaceId/learning";
|
|
76
|
+
|
|
77
|
+
app.get(base, async (context) => {
|
|
78
|
+
const workspaceId = context.req.param("workspaceId");
|
|
79
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:read");
|
|
80
|
+
const query = WorkspaceLearningPolicyHistoryQuery.safeParse({
|
|
81
|
+
limit: context.req.query("limit"),
|
|
82
|
+
});
|
|
83
|
+
if (!query.success) {
|
|
84
|
+
throw new HTTPException(422, { message: "Invalid workspace learning history query" });
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const [policy, decisions, lifecycle] = await Promise.all([
|
|
88
|
+
listWorkspaceLearningPolicyHistory(deps.db, {
|
|
89
|
+
accountId: grant.accountId,
|
|
90
|
+
workspaceId,
|
|
91
|
+
limit: query.data.limit,
|
|
92
|
+
}),
|
|
93
|
+
listGovernedLearningDecisionReceipts(deps.db, {
|
|
94
|
+
workspaceId,
|
|
95
|
+
subjectId: grant.subjectId,
|
|
96
|
+
principalKind: grant.principalKind ?? "",
|
|
97
|
+
limit: query.data.limit,
|
|
98
|
+
}),
|
|
99
|
+
listGovernedLearningActivationHistory(deps.db, {
|
|
100
|
+
workspaceId,
|
|
101
|
+
subjectId: grant.subjectId,
|
|
102
|
+
principalKind: grant.principalKind ?? "",
|
|
103
|
+
limit: query.data.limit,
|
|
104
|
+
}),
|
|
105
|
+
]);
|
|
106
|
+
return context.json(
|
|
107
|
+
WorkspaceLearningHistoryResponse.parse({
|
|
108
|
+
head: policy.head,
|
|
109
|
+
revisions: policy.revisions,
|
|
110
|
+
policyEvents: policy.events,
|
|
111
|
+
decisions: decisions.receipts,
|
|
112
|
+
activations: lifecycle.activations,
|
|
113
|
+
undos: lifecycle.undos,
|
|
114
|
+
truncated: policy.truncated || decisions.truncated || lifecycle.truncated,
|
|
115
|
+
effectiveBoundary: "next_accepted_attempt",
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
learningError(error);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
app.post(`${base}/revisions`, async (context) => {
|
|
124
|
+
const workspaceId = context.req.param("workspaceId");
|
|
125
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
|
|
126
|
+
const request = await parseBody(context, CreateWorkspaceLearningPolicyRevisionRequest);
|
|
127
|
+
try {
|
|
128
|
+
return context.json(
|
|
129
|
+
WorkspaceLearningPolicyRevision.parse(
|
|
130
|
+
await createWorkspaceLearningPolicyRevision(deps.db, {
|
|
131
|
+
operationId: request.operationId ?? randomUUID(),
|
|
132
|
+
accountId: grant.accountId,
|
|
133
|
+
workspaceId,
|
|
134
|
+
workspaceMode: request.workspaceMode,
|
|
135
|
+
sourceOverrides: request.sourceOverrides,
|
|
136
|
+
supersedesRevisionId: request.supersedesRevisionId,
|
|
137
|
+
actorSubjectId: grant.subjectId,
|
|
138
|
+
principalKind: grant.principalKind,
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
201,
|
|
142
|
+
);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
learningError(error);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
app.post(`${base}/revisions/:revisionId/activate`, async (context) => {
|
|
149
|
+
const workspaceId = context.req.param("workspaceId");
|
|
150
|
+
const revisionId = z.string().uuid().safeParse(context.req.param("revisionId"));
|
|
151
|
+
if (!revisionId.success) throw new HTTPException(422, { message: "Invalid revision id" });
|
|
152
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
|
|
153
|
+
const request = await parseBody(context, ActivateWorkspaceLearningPolicyRevisionRequest);
|
|
154
|
+
try {
|
|
155
|
+
return context.json(
|
|
156
|
+
WorkspaceLearningPolicyMutationResponse.parse(
|
|
157
|
+
await activateWorkspaceLearningPolicyRevision(deps.db, {
|
|
158
|
+
operationId: request.operationId ?? randomUUID(),
|
|
159
|
+
accountId: grant.accountId,
|
|
160
|
+
workspaceId,
|
|
161
|
+
revisionId: revisionId.data,
|
|
162
|
+
expectedCurrentRevisionId: request.expectedCurrentRevisionId,
|
|
163
|
+
expectedActivationVersion: request.expectedActivationVersion,
|
|
164
|
+
actorSubjectId: grant.subjectId,
|
|
165
|
+
principalKind: grant.principalKind,
|
|
166
|
+
reason: request.reason,
|
|
167
|
+
}),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
learningError(error);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
app.post(`${base}/rollback`, async (context) => {
|
|
176
|
+
const workspaceId = context.req.param("workspaceId");
|
|
177
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
|
|
178
|
+
const request = await parseBody(context, RollbackWorkspaceLearningPolicyRevisionRequest);
|
|
179
|
+
try {
|
|
180
|
+
return context.json(
|
|
181
|
+
WorkspaceLearningPolicyMutationResponse.parse(
|
|
182
|
+
await rollbackWorkspaceLearningPolicyRevision(deps.db, {
|
|
183
|
+
operationId: request.operationId ?? randomUUID(),
|
|
184
|
+
accountId: grant.accountId,
|
|
185
|
+
workspaceId,
|
|
186
|
+
targetRevisionId: request.targetRevisionId,
|
|
187
|
+
expectedCurrentRevisionId: request.expectedCurrentRevisionId,
|
|
188
|
+
expectedActivationVersion: request.expectedActivationVersion,
|
|
189
|
+
actorSubjectId: grant.subjectId,
|
|
190
|
+
principalKind: grant.principalKind,
|
|
191
|
+
reason: request.reason,
|
|
192
|
+
}),
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
learningError(error);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
app.post(`${base}/activations/:activationReceiptId/undo`, async (context) => {
|
|
201
|
+
const workspaceId = context.req.param("workspaceId");
|
|
202
|
+
const activationReceiptId = z
|
|
203
|
+
.string()
|
|
204
|
+
.uuid()
|
|
205
|
+
.safeParse(context.req.param("activationReceiptId"));
|
|
206
|
+
if (!activationReceiptId.success) {
|
|
207
|
+
throw new HTTPException(422, { message: "Invalid activation receipt id" });
|
|
208
|
+
}
|
|
209
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
|
|
210
|
+
const request = await parseBody(context, UndoGovernedLearningActivationHttpRequest);
|
|
211
|
+
try {
|
|
212
|
+
const undo = await undoGovernedLearningActivation(deps.db, {
|
|
213
|
+
caller: { workspaceId, subjectId: grant.subjectId },
|
|
214
|
+
request: {
|
|
215
|
+
operationId: request.operationId ?? randomUUID(),
|
|
216
|
+
activationReceiptId: activationReceiptId.data,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
// Best-effort notification; the undo receipt is already durable.
|
|
220
|
+
await publishGovernedLearningEventToSlack(deps.db, { kind: "undone", receipt: undo }).catch(
|
|
221
|
+
() => undefined,
|
|
222
|
+
);
|
|
223
|
+
return context.json(undo);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
learningError(error);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|