@opengeni/api-router 3.0.1-canary.2 → 3.0.1-canary.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/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  createOpenGeniSlackBotInteractionClient,
8
8
  startSlackInteractionPump,
9
9
  startTemporalScheduleCleanupPump
10
- } from "./chunk-PRCUIL5L.js";
10
+ } from "./chunk-RSMCZIG2.js";
11
11
 
12
12
  // src/index.ts
13
13
  import { scheduledTaskKnowledgeSource } from "@opengeni/contracts";
@@ -52,8 +52,8 @@ export declare function marketplacePlugin(url: string): {
52
52
  category: string;
53
53
  keywords: never[];
54
54
  author: {
55
- url?: never;
56
55
  name: string;
56
+ url?: never;
57
57
  email?: never;
58
58
  };
59
59
  version: string;
@@ -78,9 +78,9 @@ export declare function marketplacePlugin(url: string): {
78
78
  installation: string;
79
79
  upstream: {
80
80
  source: {
81
- url?: never;
82
81
  source: string;
83
82
  path: string;
83
+ url?: never;
84
84
  };
85
85
  policy: {
86
86
  installation: string;
@@ -124,9 +124,9 @@ export declare function marketplacePlugin(url: string): {
124
124
  installation: string;
125
125
  upstream: {
126
126
  source: {
127
- url?: never;
128
127
  source: string;
129
128
  path: string;
129
+ url?: never;
130
130
  };
131
131
  policy: {
132
132
  installation: string;
@@ -170,9 +170,9 @@ export declare function marketplacePlugin(url: string): {
170
170
  installation: string;
171
171
  upstream: {
172
172
  source: {
173
- url?: never;
174
173
  source: string;
175
174
  path: string;
175
+ url?: never;
176
176
  };
177
177
  policy: {
178
178
  installation: string;
@@ -216,9 +216,9 @@ export declare function marketplacePlugin(url: string): {
216
216
  installation: string;
217
217
  upstream: {
218
218
  source: {
219
- url?: never;
220
219
  source: string;
221
220
  path: string;
221
+ url?: never;
222
222
  };
223
223
  policy: {
224
224
  installation: string;
@@ -262,9 +262,9 @@ export declare function marketplacePlugin(url: string): {
262
262
  installation: string;
263
263
  upstream: {
264
264
  source: {
265
- url?: never;
266
265
  source: string;
267
266
  path: string;
267
+ url?: never;
268
268
  };
269
269
  policy: {
270
270
  installation: string;
@@ -308,9 +308,9 @@ export declare function marketplacePlugin(url: string): {
308
308
  installation: string;
309
309
  upstream: {
310
310
  source: {
311
- url?: never;
312
311
  source: string;
313
312
  path: string;
313
+ url?: never;
314
314
  };
315
315
  policy: {
316
316
  installation: string;
@@ -0,0 +1,6 @@
1
+ import type { ApiRouteDeps } from "@opengeni/core";
2
+ import type { Context, Hono } from "hono";
3
+ export declare function admitManagedSignInMutation(context: Context, deps: ApiRouteDeps): Promise<void>;
4
+ export declare function registerManagedSignInMethodRoutes(app: Hono, deps: ApiRouteDeps): void;
5
+ /** Intercept only database-backed, product-issued explicit connect states. */
6
+ export declare function handleManagedSignInConnectCallback(context: Context, deps: ApiRouteDeps, provider: "google" | "github"): Promise<Response | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "3.0.1-canary.2",
3
+ "version": "3.0.1-canary.4",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,24 +43,24 @@
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@llamaindex/liteparse": "2.14.2",
45
45
  "@modelcontextprotocol/sdk": "^1.29.0",
46
- "@opengeni/agent-proto": "^0.6.0-canary.7",
47
- "@opengeni/artifact-tool": "^0.3.29-canary.2",
48
- "@opengeni/capabilities": "^0.3.4-canary.2",
49
- "@opengeni/codemode": "^0.5.9-canary.2",
50
- "@opengeni/codex": "^0.2.23-canary.4",
51
- "@opengeni/config": "^1.2.2-canary.2",
52
- "@opengeni/contracts": "^4.1.0-canary.2",
53
- "@opengeni/core": "^3.0.1-canary.2",
54
- "@opengeni/db": "^5.0.1-canary.2",
55
- "@opengeni/documents": "^0.8.30-canary.2",
56
- "@opengeni/events": "^0.4.28-canary.2",
57
- "@opengeni/github": "^0.7.13-canary.2",
58
- "@opengeni/network": "^0.3.1-canary.19",
59
- "@opengeni/observability": "^0.8.29-canary.2",
60
- "@opengeni/runtime": "^3.0.1-canary.2",
61
- "@opengeni/storage": "^0.2.130-canary.2",
62
- "@opengeni/tool-gateway": "^0.1.10-canary.2",
63
- "@opengeni/xai-subscription": "^0.1.4-canary.19",
46
+ "@opengeni/agent-proto": "^0.6.0-canary.9",
47
+ "@opengeni/artifact-tool": "^0.3.29-canary.4",
48
+ "@opengeni/capabilities": "^0.3.4-canary.4",
49
+ "@opengeni/codemode": "^0.5.9-canary.4",
50
+ "@opengeni/codex": "^0.2.23-canary.6",
51
+ "@opengeni/config": "^1.2.2-canary.4",
52
+ "@opengeni/contracts": "^4.1.0-canary.4",
53
+ "@opengeni/core": "^3.0.1-canary.4",
54
+ "@opengeni/db": "^5.0.1-canary.4",
55
+ "@opengeni/documents": "^0.8.30-canary.4",
56
+ "@opengeni/events": "^0.4.28-canary.4",
57
+ "@opengeni/github": "^0.7.13-canary.4",
58
+ "@opengeni/network": "^0.3.1-canary.21",
59
+ "@opengeni/observability": "^0.8.29-canary.4",
60
+ "@opengeni/runtime": "^3.0.1-canary.4",
61
+ "@opengeni/storage": "^0.2.130-canary.4",
62
+ "@opengeni/tool-gateway": "^0.1.10-canary.4",
63
+ "@opengeni/xai-subscription": "^0.1.4-canary.21",
64
64
  "@temporalio/client": "^1.17.0",
65
65
  "better-auth": "^1.6.14",
66
66
  "hono": "^4.12.18",
package/src/app.ts CHANGED
@@ -106,6 +106,7 @@ import {
106
106
  runManagedAuthProvider,
107
107
  } from "./auth/managed-auth-attempt-context";
108
108
  import { createManagedEmailTransport } from "./auth/managed-email";
109
+ import { startManagedSignInNotificationDelivery } from "./auth/managed-sign-in-notifications";
109
110
  import {
110
111
  assertManagedEmailTransportMetadata,
111
112
  assertOrganizationUserSetupQueryTransportConfigured,
@@ -207,6 +208,10 @@ import { registerOrganizationMembershipRoutes } from "./routes/organization-memb
207
208
  import { registerOrganizationSessionRoutes } from "./routes/organization-sessions";
208
209
  import { registerOrganizationRecoveryRoutes } from "./routes/organization-recovery";
209
210
  import { registerManagedOnboardingRoutes } from "./routes/managed-onboarding";
211
+ import {
212
+ registerManagedSignInMethodRoutes,
213
+ handleManagedSignInConnectCallback,
214
+ } from "./routes/managed-sign-in-methods";
210
215
  import {
211
216
  registerManagedAuthSessionSetRoutes,
212
217
  requireManagedAuthProviderRouteAllowed,
@@ -300,6 +305,7 @@ export function createAppComposition(deps: AppDependencies): {
300
305
  const managedAuthSessionAdapter =
301
306
  deps.managedAuthSessionAdapter ??
302
307
  (managedAuth ? createBetterAuthSessionAdapter(managedAuth, deps.db) : null);
308
+ if (managedAuth) startManagedSignInNotificationDelivery(deps.db, managedEmailTransport);
303
309
  const objectStorage =
304
310
  deps.objectStorage === undefined ? createObjectStorage(deps.settings) : deps.objectStorage;
305
311
  let documentServices: DocumentServices | null = deps.documentServices ?? null;
@@ -670,10 +676,61 @@ export function createAppComposition(deps: AppDependencies): {
670
676
  // wildcard handler or the provider returns its own 404 first.
671
677
  registerManagedOnboardingRoutes(app, routeDeps);
672
678
  registerManagedAuthSessionSetRoutes(app, routeDeps);
679
+ registerManagedSignInMethodRoutes(app, routeDeps);
673
680
  if (managedAuth) {
674
681
  app.on(["GET", "POST"], "/v1/auth/*", async (c) => {
675
682
  const pathname = new URL(c.req.url).pathname;
676
683
  const oauthCallbackProvider = managedAuthOAuthCallbackProvider(pathname);
684
+ if (pathname === "/v1/auth/sign-in/social" && c.req.method === "POST") {
685
+ const body = await c.req.raw
686
+ .clone()
687
+ .json()
688
+ .catch(() => null);
689
+ if (
690
+ !body ||
691
+ !["google", "github"].includes(body.provider) ||
692
+ Object.prototype.hasOwnProperty.call(body, "idToken")
693
+ ) {
694
+ return c.json(
695
+ {
696
+ code: "SIGN_IN_METHOD_OAUTH_REDIRECT_REQUIRED",
697
+ message: "Use the browser OAuth sign-in redirect",
698
+ },
699
+ 403,
700
+ );
701
+ }
702
+ }
703
+ if (oauthCallbackProvider) {
704
+ const connectResponse = await handleManagedSignInConnectCallback(
705
+ c,
706
+ routeDeps,
707
+ oauthCallbackProvider,
708
+ );
709
+ if (connectResponse) return connectResponse;
710
+ }
711
+ if (
712
+ new Set([
713
+ "link-social",
714
+ "unlink-account",
715
+ "list-accounts",
716
+ "set-password",
717
+ "change-password",
718
+ "change-email",
719
+ "update-user",
720
+ "delete-user",
721
+ "get-access-token",
722
+ "refresh-token",
723
+ "account-info",
724
+ ]).has(pathname.slice("/v1/auth/".length))
725
+ ) {
726
+ return c.json(
727
+ {
728
+ code: "SIGN_IN_METHOD_PRODUCT_ROUTE_REQUIRED",
729
+ message: "Use personal sign-in method settings",
730
+ },
731
+ 403,
732
+ );
733
+ }
677
734
  if (deps.settings.managedAuthSessionSetMode === "legacy") {
678
735
  return oauthCallbackProvider
679
736
  ? await runManagedAuthProvider(
@@ -11,6 +11,15 @@ type ManagedAuthAttemptContext =
11
11
  | { kind: "discard_provider_session" };
12
12
 
13
13
  const managedAuthAttemptStorage = new AsyncLocalStorage<ManagedAuthAttemptContext>();
14
+ const managedLinkIntentStorage = new AsyncLocalStorage<string>();
15
+
16
+ export function runManagedSignInConnect<T>(intentId: string, action: () => T): T {
17
+ return managedLinkIntentStorage.run(intentId, action);
18
+ }
19
+
20
+ export function currentManagedSignInConnectIntent(): string | undefined {
21
+ return managedLinkIntentStorage.getStore();
22
+ }
14
23
 
15
24
  export function runManagedAuthAttempt<T>(
16
25
  transactionId: string,
@@ -8,6 +8,17 @@ import type { ManagedAuth } from "@opengeni/core";
8
8
  import { createHmac, timingSafeEqual } from "node:crypto";
9
9
  import { runManagedAuthAttempt } from "./managed-auth-attempt-context";
10
10
 
11
+ /** Server-only proof for a product-owned endpoint; never returned to a browser. */
12
+ export async function managedAuthSelectedProofHeaders(
13
+ auth: ManagedAuth,
14
+ token: string,
15
+ ): Promise<Headers> {
16
+ const context = await auth.$context;
17
+ return new Headers({
18
+ cookie: `${context.authCookies.sessionToken.name}=${signedCookieValue(token, context.secret)}`,
19
+ });
20
+ }
21
+
11
22
  export function createBetterAuthSessionAdapter(
12
23
  auth: ManagedAuth,
13
24
  db: Database,
@@ -14,16 +14,18 @@ import {
14
14
  } from "@opengeni/db/canonical-human-identities";
15
15
  import { betterAuth } from "better-auth";
16
16
  import { createEmailVerificationToken } from "better-auth/api";
17
- import { hashPassword } from "better-auth/crypto";
17
+ import { hashPassword, verifyPassword } from "better-auth/crypto";
18
18
  import { sql } from "drizzle-orm";
19
19
  import { Pool } from "pg";
20
20
 
21
21
  import { decideCanonicalHumanSessionAdmission } from "./canonical-human-session-admission";
22
+ import { deliverManagedSignInNotification } from "./managed-sign-in-notifications";
22
23
  import {
23
24
  currentManagedAuthProviderId,
24
25
  currentManagedAuthAttemptId,
25
26
  recordCurrentManagedAuthSession,
26
27
  shouldDiscardCurrentManagedAuthProviderSession,
28
+ currentManagedSignInConnectIntent,
27
29
  } from "./managed-auth-attempt-context";
28
30
 
29
31
  // `ManagedAuth` (the Better Auth `Auth<any>` alias) is owned by @opengeni/core
@@ -66,6 +68,10 @@ export async function hashManagedAuthPassword(password: string): Promise<string>
66
68
  return await hashPassword(password);
67
69
  }
68
70
 
71
+ export async function verifyManagedAuthPassword(password: string, hash: string): Promise<boolean> {
72
+ return await verifyPassword({ password, hash });
73
+ }
74
+
69
75
  export function createManagedAuth(
70
76
  settings: Settings,
71
77
  db: Database,
@@ -180,7 +186,18 @@ export function createManagedAuth(
180
186
  updatedAt: "updated_at",
181
187
  },
182
188
  accountLinking: {
183
- enabled: false,
189
+ enabled: true,
190
+ requireLocalEmailVerified: true,
191
+ trustedProviders: [],
192
+ allowDifferentEmails: false,
193
+ },
194
+ additionalFields: {
195
+ managedLinkIntentId: {
196
+ type: "string",
197
+ fieldName: "managed_link_intent_id",
198
+ input: false,
199
+ returned: false,
200
+ },
184
201
  },
185
202
  encryptOAuthTokens: true,
186
203
  storeStateStrategy: "database",
@@ -264,6 +281,26 @@ export function createManagedAuth(
264
281
  },
265
282
  },
266
283
  databaseHooks: {
284
+ account: {
285
+ create: {
286
+ before: async (account) => ({
287
+ data: {
288
+ ...account,
289
+ ...(currentManagedSignInConnectIntent()
290
+ ? { managedLinkIntentId: currentManagedSignInConnectIntent() }
291
+ : {}),
292
+ },
293
+ }),
294
+ after: async (account) => {
295
+ if (
296
+ !["google", "github"].includes(account.providerId) ||
297
+ currentManagedSignInConnectIntent()
298
+ )
299
+ return;
300
+ await deliverManagedSignInNotification(db, managedEmailTransport, account.id);
301
+ },
302
+ },
303
+ },
267
304
  session: {
268
305
  create: {
269
306
  before: async (session) => {
@@ -0,0 +1,81 @@
1
+ import type { ManagedEmailTransport } from "@opengeni/core";
2
+ import type { Database } from "@opengeni/db";
3
+ import { sql } from "drizzle-orm";
4
+ import { z } from "zod";
5
+
6
+ const Claim = z.object({
7
+ operationId: z.string().uuid(),
8
+ leaseId: z.string().uuid(),
9
+ email: z.string().email(),
10
+ provider: z.enum(["password", "google", "github"]),
11
+ sender: z.string(),
12
+ });
13
+ const started = new WeakSet<object>();
14
+
15
+ export async function deliverManagedSignInNotification(
16
+ db: Database,
17
+ transport: ManagedEmailTransport,
18
+ operationId: string | null = null,
19
+ ): Promise<"sent" | "failed" | "outcome_unknown"> {
20
+ try {
21
+ const result = await db.execute(
22
+ sql`select claim_managed_sign_in_notification(${operationId}::uuid,${transport.sender},${transport.idempotency.scope},${transport.idempotency.retentionSeconds}::integer) as claim`,
23
+ );
24
+ const row = (Array.isArray(result) ? result : result.rows)[0] as { claim: unknown } | undefined;
25
+ if (!row?.claim) return "outcome_unknown";
26
+ const claim = Claim.parse(row.claim);
27
+ let status: "sent" | "failed" | "outcome_unknown" = "outcome_unknown";
28
+ let timer: ReturnType<typeof setTimeout> | undefined;
29
+ try {
30
+ const response = await Promise.race([
31
+ transport.send({
32
+ kind: "sign_in_method_changed",
33
+ from: claim.sender,
34
+ to: claim.email,
35
+ idempotencyKey: `sign-in-method:${claim.operationId}`,
36
+ subject: "Your OpenGeni sign-in methods changed",
37
+ text: `Your OpenGeni ${claim.provider} sign-in method changed. If this was not you, reset your password and contact your administrator.`,
38
+ html: `<p>Your OpenGeni ${claim.provider} sign-in method changed. If this was not you, reset your password and contact your administrator.</p>`,
39
+ }),
40
+ new Promise<{ status: "outcome_unknown" }>((resolve) => {
41
+ timer = setTimeout(() => resolve({ status: "outcome_unknown" }), 5_000);
42
+ }),
43
+ ]);
44
+ status = response.status;
45
+ } catch {
46
+ /* Retain the durable unknown claim for idempotent retry. */
47
+ } finally {
48
+ if (timer) clearTimeout(timer);
49
+ }
50
+ await db.execute(
51
+ sql`select settle_managed_sign_in_notification(${claim.operationId}::uuid,${claim.leaseId}::uuid,${status})`,
52
+ );
53
+ return status;
54
+ } catch {
55
+ // Security state is already committed. A failed claim/ack must never be
56
+ // reported as a failed method change; its durable obligation remains live.
57
+ return "outcome_unknown";
58
+ }
59
+ }
60
+
61
+ export function startManagedSignInNotificationDelivery(
62
+ db: Database,
63
+ transport: ManagedEmailTransport,
64
+ ): void {
65
+ if (started.has(db as object)) return;
66
+ started.add(db as object);
67
+ let active = false;
68
+ const drain = async () => {
69
+ if (active) return;
70
+ active = true;
71
+ try {
72
+ for (let count = 0; count < 10; count++)
73
+ if ((await deliverManagedSignInNotification(db, transport)) === "outcome_unknown") break;
74
+ } finally {
75
+ active = false;
76
+ }
77
+ };
78
+ const timer = setInterval(() => void drain(), 60_000);
79
+ (timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.();
80
+ void drain();
81
+ }
@@ -210,6 +210,18 @@ export async function completeGitHubAppConnect(
210
210
  state.providerId,
211
211
  acquiring,
212
212
  );
213
+ // Recover a repeated install callback only while its next stage is still
214
+ // current. A completed/superseded attempt returns home without reopening OAuth.
215
+ if (
216
+ claim.status === "replayed" &&
217
+ state.phase === "install" &&
218
+ claim.attempt.revision === stored.attempt.revision &&
219
+ claim.attempt.nextAction.type === "authorize"
220
+ )
221
+ return new Response(null, {
222
+ status: 302,
223
+ headers: { Location: claim.attempt.nextAction.url },
224
+ });
213
225
  if (claim.status !== "replayed") {
214
226
  const provider =
215
227
  state.providerId === "github-lens" ? deps.prReviewGithubAppApi : deps.githubAppApi;
@@ -390,14 +402,19 @@ export async function completeGitHubAppConnect(
390
402
  },
391
403
  };
392
404
  }
393
- if (acquiring)
394
- await finishOAuthAcquisition(
395
- deps.db,
396
- state,
397
- { ...operation, commit: prepared.commit },
398
- state.providerId,
399
- );
400
- else await finishConnectOperation(deps.db, state, { ...operation, commit: prepared.commit });
405
+ const completed = acquiring
406
+ ? await finishOAuthAcquisition(
407
+ deps.db,
408
+ state,
409
+ { ...operation, commit: prepared.commit },
410
+ state.providerId,
411
+ )
412
+ : await finishConnectOperation(deps.db, state, { ...operation, commit: prepared.commit });
413
+ // Installation is only the first half of consent. Keep this browser on
414
+ // the exact authorization stage committed above; polling still waits for
415
+ // the final owner proof before reporting connection completion.
416
+ if (state.phase === "install" && completed.nextAction.type === "authorize")
417
+ return new Response(null, { status: 302, headers: { Location: completed.nextAction.url } });
401
418
  }
402
419
  } catch {
403
420
  // Preserve unknown outcomes; never replay provider authorization to recover.
package/src/mcp/server.ts CHANGED
@@ -5787,7 +5787,7 @@ function registerCapabilityDiscoveryTools(
5787
5787
  "capability_catalog_search",
5788
5788
  {
5789
5789
  description:
5790
- "Search OpenGeni's reviewed workspace capability catalog when the user asks to add an integration or the task needs a capability that is not currently usable. Search by the outcome needed (for example `GitHub repositories`, `product analytics`, or `Slack notifications`), compare the returned candidates, and prefer a ready or verified exact match. This only reads secret-free metadata; it never installs, connects, or authorizes anything.",
5790
+ "Find integrations in OpenGeni's reviewed workspace catalog when the user asks to add one or needed access is missing. Search by integration name or task outcome. Results describe setup status and provide setup.nextAction when human setup can be requested. Use available tools directly for ready candidates. This reads metadata only and does not connect or authorize anything.",
5791
5791
  inputSchema: {
5792
5792
  query: z4.string().min(1).max(500),
5793
5793
  limit: z4.number().int().min(1).max(20).optional(),
@@ -5817,6 +5817,10 @@ function registerCapabilityDiscoveryTools(
5817
5817
  setup: {
5818
5818
  ...setups[index]!,
5819
5819
  requiredVariables: capabilityRequiredVariables(item),
5820
+ nextAction:
5821
+ setups[index]!.status === "authorization_required"
5822
+ ? { toolName: "capability_authorization_request", capabilityId: item.id }
5823
+ : null,
5820
5824
  },
5821
5825
  }));
5822
5826
  return json({ query, matches });
@@ -5827,7 +5831,7 @@ function registerCapabilityDiscoveryTools(
5827
5831
  "capability_authorization_request",
5828
5832
  {
5829
5833
  description:
5830
- "After capability_catalog_search and after explaining one chosen recommendation to the user, post exactly one in-session human authorization card for that catalog capability. This tool never grants access, enables a capability, reads a secret, or mints provider credentials; the authenticated user must click and confirm the provider/domain flow. Do not call it for a candidate reported ready or unavailable.",
5834
+ "Show a Connect card in this chat for a suitable capability returned by capability_catalog_search with setup.nextAction. Supply its capability ID and a brief rationale explaining how it helps the task; no separate confirmation is needed before showing the card. Requesting setup needs no integration-management permission and grants no access. The authenticated human completes setup through the card; never ask them to paste credentials into chat. Do not request another card for the same pending setup, or for a candidate reported ready or unavailable.",
5831
5835
  inputSchema: {
5832
5836
  capabilityId: z4.string().min(1).max(512),
5833
5837
  rationale: z4.string().min(1).max(2000),
@@ -23,6 +23,9 @@ import {
23
23
  import type { Context, Hono } from "hono";
24
24
  import { HTTPException } from "hono/http-exception";
25
25
  import { z } from "zod";
26
+ import { admitManagedSignInMutation } from "./managed-sign-in-methods";
27
+ import { managedAuthSecretRequestDigest } from "@opengeni/core/managed-auth-session-sets";
28
+ import { deliverManagedSignInNotification } from "../auth/managed-sign-in-notifications";
26
29
 
27
30
  const BindingId = z.string().uuid();
28
31
 
@@ -86,6 +89,7 @@ async function mutate(
86
89
  operationId: string;
87
90
  expectedIdentityRevision: number;
88
91
  reason: string;
92
+ verifiedManagedRecovery?: boolean;
89
93
  },
90
94
  ): Promise<Response> {
91
95
  const identity = await requestIdentity(context, deps);
@@ -104,10 +108,27 @@ async function mutate(
104
108
  ...(input.providerId ? { providerId: input.providerId } : {}),
105
109
  ...(input.providerAccountId ? { providerAccountId: input.providerAccountId } : {}),
106
110
  reason: input.reason,
111
+ ...(input.verifiedManagedRecovery
112
+ ? {
113
+ verifiedRecovery: {
114
+ authSessionId: identity.authSessionId,
115
+ requestDigest: managedAuthSecretRequestDigest(
116
+ deps.settings.betterAuthSecret!,
117
+ input,
118
+ ),
119
+ },
120
+ }
121
+ : {}),
107
122
  ...(actorFence ? { actorFence } : {}),
108
123
  }),
109
124
  );
110
125
  if (actorFence) markManagedAuthRequestActorTransitionApplied(context.req.raw);
126
+ if (input.verifiedManagedRecovery)
127
+ await deliverManagedSignInNotification(
128
+ deps.db,
129
+ deps.managedEmailTransport,
130
+ input.operationId,
131
+ );
111
132
  return context.json(response);
112
133
  } catch (error) {
113
134
  return identityError(context, error);
@@ -132,6 +153,11 @@ export function registerCanonicalHumanIdentityRoutes(app: Hono, deps: ApiRouteDe
132
153
 
133
154
  app.post(`${base}/login-bindings`, async (context) => {
134
155
  const request = await parseBody(context, LinkCanonicalHumanLoginBindingRequest);
156
+ if (["google", "github", "credential"].includes(request.providerId.trim().toLowerCase())) {
157
+ throw new HTTPException(403, {
158
+ message: "Use personal sign-in method settings with fresh provider proof",
159
+ });
160
+ }
135
161
  return await mutate(context, deps, {
136
162
  operationType: "link",
137
163
  ...request,
@@ -141,6 +167,19 @@ export function registerCanonicalHumanIdentityRoutes(app: Hono, deps: ApiRouteDe
141
167
 
142
168
  app.delete(`${base}/login-bindings/:bindingId`, async (context) => {
143
169
  const request = await parseBody(context, CanonicalHumanBindingOperationRequest);
170
+ const actor = await requestIdentity(context, deps);
171
+ const projection = await getCanonicalHumanIdentityProjection(deps.db, actor.authUserId);
172
+ if (
173
+ projection.loginBindings.some(
174
+ (binding) =>
175
+ binding.id === bindingId(context) &&
176
+ ["google", "github", "credential"].includes(binding.providerId),
177
+ )
178
+ ) {
179
+ throw new HTTPException(403, {
180
+ message: "Use personal sign-in method settings with last-method protection",
181
+ });
182
+ }
144
183
  return await mutate(context, deps, {
145
184
  operationType: "unlink",
146
185
  bindingId: bindingId(context),
@@ -151,6 +190,19 @@ export function registerCanonicalHumanIdentityRoutes(app: Hono, deps: ApiRouteDe
151
190
 
152
191
  app.post(`${base}/login-bindings/:bindingId/recovery`, async (context) => {
153
192
  const request = await parseBody(context, CanonicalHumanBindingOperationRequest);
193
+ const actor = await requestIdentity(context, deps);
194
+ const projection = await getCanonicalHumanIdentityProjection(deps.db, actor.authUserId);
195
+ if (
196
+ projection.loginBindings.some(
197
+ (binding) =>
198
+ binding.id === bindingId(context) &&
199
+ ["google", "github", "credential"].includes(binding.providerId),
200
+ )
201
+ ) {
202
+ throw new HTTPException(403, {
203
+ message: "Managed-provider recovery cannot be initiated through raw binding management",
204
+ });
205
+ }
154
206
  return await mutate(context, deps, {
155
207
  operationType: "begin_recovery",
156
208
  bindingId: bindingId(context),
@@ -161,11 +213,20 @@ export function registerCanonicalHumanIdentityRoutes(app: Hono, deps: ApiRouteDe
161
213
 
162
214
  app.post(`${base}/login-bindings/:bindingId/recovery/complete`, async (context) => {
163
215
  const request = await parseBody(context, CanonicalHumanBindingOperationRequest);
216
+ const actor = await requestIdentity(context, deps);
217
+ const projection = await getCanonicalHumanIdentityProjection(deps.db, actor.authUserId);
218
+ const managed = projection.loginBindings.some(
219
+ (binding) =>
220
+ binding.id === bindingId(context) &&
221
+ ["google", "github", "credential"].includes(binding.providerId),
222
+ );
223
+ if (managed) await admitManagedSignInMutation(context, deps);
164
224
  return await mutate(context, deps, {
165
225
  operationType: "recover",
166
226
  bindingId: bindingId(context),
167
227
  ...request,
168
228
  operationId: request.operationId ?? randomUUID(),
229
+ ...(managed ? { verifiedManagedRecovery: true } : {}),
169
230
  });
170
231
  });
171
232
  }