@camstack/server 1.2.480 → 1.2.481

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.
@@ -21,6 +21,10 @@ const types_1 = require("@camstack/types");
21
21
  const server_1 = require("@trpc/server");
22
22
  const zod_1 = require("zod");
23
23
  const auth_rate_limit_js_1 = require("../../auth/auth-rate-limit.js");
24
+ const auth_provider_gate_js_1 = require("../../auth/auth-provider-gate.js");
25
+ const external_link_intents_js_1 = require("../../auth/external-link-intents.js");
26
+ const passkey_provider_js_1 = require("../../auth/passkey-provider.js");
27
+ const second_factors_js_1 = require("../../auth/second-factors.js");
24
28
  const session_cookie_js_1 = require("../../auth/session-cookie.js");
25
29
  const session_mint_js_1 = require("../../auth/session-mint.js");
26
30
  const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
@@ -63,7 +67,7 @@ const ceremonyProcedure = makeRateLimitedProcedure(CEREMONY_LIMITER);
63
67
  * the `user-passkeys` cap. The client renders a chooser from this list
64
68
  * so a future alternate 2FA cap works with zero login-flow changes.
65
69
  */
66
- const SecondFactorSchema = zod_1.z.enum(['totp', 'passkey']);
70
+ // `SecondFactorSchema` lives in `auth/second-factors.ts` (shared with the SSO finish).
67
71
  /**
68
72
  * Login response.
69
73
  *
@@ -87,93 +91,8 @@ const LoginResultSchema = zod_1.z.object({
87
91
  isAdmin: zod_1.z.boolean(),
88
92
  }),
89
93
  requiresTotp: zod_1.z.boolean().optional(),
90
- secondFactors: zod_1.z.array(SecondFactorSchema).optional(),
94
+ secondFactors: zod_1.z.array(second_factors_js_1.SecondFactorSchema).optional(),
91
95
  });
92
- // ── Passkey (user-passkeys cap) resolution ───────────────────────────
93
- //
94
- // Cap-agnostic: the passkey provider is resolved through the
95
- // `user-passkeys` CAPABILITY, never by addon id. Any addon registering
96
- // the cap works. Resolution mirrors `hwaccel.router`:
97
- // 1. hub-in-process collection provider (a future hub-resident impl),
98
- // 2. else the hub-local forked child over UDS via the Moleculer cap
99
- // proxy (`createCapabilityProxy(cap, 'hub')`) — the same routing the
100
- // generated cap routers use.
101
- // The proxy is loosely typed (`Record<method, (params)=>Promise<unknown>>`);
102
- // its outputs are narrowed through the cap's own Zod schemas rather than
103
- // cast to the provider type (repo rule: no `as` on cross-boundary values).
104
- const OptionsJsonSchema = zod_1.z.object({ optionsJSON: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) });
105
- const FinishRegistrationSchema = zod_1.z.object({ success: zod_1.z.literal(true), credentialId: zod_1.z.string() });
106
- const FinishAuthenticationSchema = zod_1.z.object({ verified: zod_1.z.boolean() });
107
- const FinishDiscoverableAuthenticationSchema = zod_1.z.object({
108
- verified: zod_1.z.boolean(),
109
- userId: zod_1.z.string().nullable(),
110
- });
111
- const RemovePasskeySchema = zod_1.z.object({ success: zod_1.z.literal(true) });
112
- const SecondFactorPreferenceSchema = zod_1.z.object({ enabled: zod_1.z.boolean() });
113
- const SetSecondFactorPreferenceSchema = zod_1.z.object({ success: zod_1.z.literal(true) });
114
- function proxyPasskeyProvider(proxy) {
115
- const call = async (method, params) => {
116
- const fn = proxy[method];
117
- if (typeof fn !== 'function')
118
- throw new Error(`user-passkeys proxy missing method '${method}'`);
119
- return fn(params);
120
- };
121
- return {
122
- beginRegistration: async (input) => OptionsJsonSchema.parse(await call('beginRegistration', input)),
123
- finishRegistration: async (input) => FinishRegistrationSchema.parse(await call('finishRegistration', input)),
124
- beginAuthentication: async (input) => OptionsJsonSchema.parse(await call('beginAuthentication', input)),
125
- finishAuthentication: async (input) => FinishAuthenticationSchema.parse(await call('finishAuthentication', input)),
126
- beginDiscoverableAuthentication: async (input) => OptionsJsonSchema.parse(await call('beginDiscoverableAuthentication', input)),
127
- finishDiscoverableAuthentication: async (input) => FinishDiscoverableAuthenticationSchema.parse(await call('finishDiscoverableAuthentication', input)),
128
- listPasskeys: async (input) => zod_1.z.array(types_1.PasskeySummarySchema).parse(await call('listPasskeys', input)),
129
- removePasskey: async (input) => RemovePasskeySchema.parse(await call('removePasskey', input)),
130
- getSecondFactorPreference: async (input) => SecondFactorPreferenceSchema.parse(await call('getSecondFactorPreference', input)),
131
- setSecondFactorPreference: async (input) => SetSecondFactorPreferenceSchema.parse(await call('setSecondFactorPreference', input)),
132
- };
133
- }
134
- function resolvePasskeyProvider(registry, moleculer) {
135
- const local = registry?.getCollection('user-passkeys')?.[0] ?? null;
136
- if (local)
137
- return local;
138
- const proxy = moleculer?.createCapabilityProxy('user-passkeys', 'hub') ?? null;
139
- return proxy ? proxyPasskeyProvider(proxy) : null;
140
- }
141
- /**
142
- * Whether the user has at least one enrolled passkey. Never throws — a
143
- * missing provider or a transport hiccup degrades to `false` so the
144
- * login flow is never blocked by passkey introspection.
145
- */
146
- async function userHasPasskey(userId, provider) {
147
- if (!provider)
148
- return false;
149
- try {
150
- const list = await provider.listPasskeys({ userId });
151
- return list.length > 0;
152
- }
153
- catch {
154
- return false;
155
- }
156
- }
157
- /**
158
- * Whether the user OPTED IN to their passkey doubling as a mandatory
159
- * second factor after a password login. Enrolling a passkey alone only
160
- * enables passkey-FIRST sign-in — this flag (default OFF, stored by the
161
- * `user-passkeys` provider) is what re-arms the 2FA gate. Never throws:
162
- * a missing provider, a provider predating the method, or a transport
163
- * hiccup all read as `false` so the preference lookup can never lock a
164
- * user out of the password flow.
165
- */
166
- async function passkeySecondFactorOptedIn(userId, provider) {
167
- if (!provider || typeof provider.getSecondFactorPreference !== 'function')
168
- return false;
169
- try {
170
- const pref = await provider.getSecondFactorPreference({ userId });
171
- return pref.enabled;
172
- }
173
- catch {
174
- return false;
175
- }
176
- }
177
96
  // ── Login-method aggregation (login-method cap) ──────────────────────
178
97
  //
179
98
  // The PUBLIC `listLoginMethods` procedure walks the `login-method`
@@ -197,6 +116,17 @@ const PublicLoginMethodSchema = zod_1.z.discriminatedUnion('kind', [
197
116
  PublicWidgetLoginMethodSchema,
198
117
  types_1.PasskeyLoginMethodSchema,
199
118
  ]);
119
+ /** One of the caller's linked sign-in identities, as the profile shows it (D782). */
120
+ const OwnExternalIdentitySchema = zod_1.z.object({
121
+ provider: zod_1.z.string(),
122
+ subject: zod_1.z.string(),
123
+ label: zod_1.z.string().optional(),
124
+ linkedAt: zod_1.z.number(),
125
+ /** The provider instance's button label, or `null` when it is gone / disabled. */
126
+ providerLabel: zod_1.z.string().nullable(),
127
+ /** Whether the provider instance is configured and enabled right now. */
128
+ available: zod_1.z.boolean(),
129
+ });
200
130
  // ── Share tokens (grid-view standalone share links) ──────────────────
201
131
  //
202
132
  // Wire schemas for the `csv_*` share-token surface. The summary shape
@@ -324,7 +254,7 @@ const MeSchema = zod_1.z
324
254
  scope: EffectiveScopeSchema.optional(),
325
255
  })
326
256
  .nullable();
327
- function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService()) {
257
+ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService(), linkIntents = new external_link_intents_js_1.ExternalLinkIntents()) {
328
258
  const requireShareTokens = () => {
329
259
  if (!shareTokens) {
330
260
  throw new Error('Share tokens unavailable — service not wired on this node');
@@ -362,39 +292,91 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
362
292
  throw error;
363
293
  }
364
294
  };
295
+ /** The second factors a primary login still owes (`auth/second-factors.ts`). */
296
+ const collectSecondFactorsFor = async (userId, primary) => {
297
+ const userMgmt = registry?.getSingleton('user-management');
298
+ return (0, second_factors_js_1.collectSecondFactors)({
299
+ ...(userMgmt && typeof userMgmt.getTotpStatus === 'function'
300
+ ? { getTotpStatus: (input) => userMgmt.getTotpStatus(input) }
301
+ : {}),
302
+ passkeyProvider: () => (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer),
303
+ }, userId, primary);
304
+ };
365
305
  /**
366
- * The second factors `userId` must still satisfy after a successful
367
- * PRIMARY factor. 2FA applies uniformly to every primary method:
368
- *
369
- * • `totp` — whenever enrolled, regardless of how the user signed in.
370
- * • `passkey` — ONLY when the user both has an enrolled passkey AND
371
- * explicitly opted in (`getSecondFactorPreference`, default OFF —
372
- * enrolling a passkey alone just enables passkey-first sign-in),
373
- * and NEVER when the primary factor already WAS the passkey
374
- * (re-proving the factor that just signed in adds nothing).
375
- *
376
- * Passkey introspection is resilient (missing provider / transport
377
- * error ⇒ no passkey factor) so it can never block a login; a TOTP
378
- * status failure still propagates — fail-open there would silently
379
- * skip an enrolled factor.
306
+ * The login page's methods: every enabled auth addon's `login-method`
307
+ * contributions (D777), validated and deduplicated. Shared by
308
+ * `listLoginMethods` and the D782 linking procedures.
380
309
  */
381
- const collectSecondFactors = async (userId, primary) => {
382
- const userMgmt = registry?.getSingleton('user-management');
383
- const totpStatus = userMgmt && typeof userMgmt.getTotpStatus === 'function'
384
- ? await userMgmt.getTotpStatus({ userId })
385
- : { enabled: false };
386
- const factors = [];
387
- if (totpStatus.enabled)
388
- factors.push('totp');
389
- if (primary !== 'passkey') {
390
- const provider = resolvePasskeyProvider(registry, moleculer);
391
- const enrolled = await userHasPasskey(userId, provider);
392
- if (enrolled && (await passkeySecondFactorOptedIn(userId, provider))) {
393
- factors.push('passkey');
310
+ const aggregateLoginMethods = async () => {
311
+ if (!registry)
312
+ return [];
313
+ const out = [];
314
+ // Defense-in-depth dedupe: a routing bug in a grouped runner once
315
+ // resolved every provider to the same addon (3× identical magic-link
316
+ // buttons) — a duplicated contribution id must never render twice.
317
+ const seenIds = new Set();
318
+ for (const [addonId, provider] of registry.getCollectionEntries('login-method')) {
319
+ // The operator's Enable/Disable on Identity → Authentication is the
320
+ // addon's ONE enable (D777). It toggles `auth-provider` /
321
+ // `user-passkeys`, never `login-method` — so ask the governing caps.
322
+ if ((0, auth_provider_gate_js_1.isAuthAddonDisabled)(registry, addonId))
323
+ continue;
324
+ let contributions;
325
+ try {
326
+ contributions = await provider.getLoginMethods();
327
+ }
328
+ catch {
329
+ continue;
330
+ }
331
+ for (const raw of contributions) {
332
+ if (seenIds.has(raw.id))
333
+ continue;
334
+ if (raw.kind === 'redirect') {
335
+ const parsed = types_1.RedirectLoginMethodSchema.safeParse(raw);
336
+ if (parsed.success) {
337
+ out.push(parsed.data);
338
+ seenIds.add(parsed.data.id);
339
+ }
340
+ continue;
341
+ }
342
+ if (raw.kind === 'passkey') {
343
+ // Native ceremony — passes through untouched, no server-side
344
+ // enrichment (rpId/origin are already stamped by the addon).
345
+ const parsed = types_1.PasskeyLoginMethodSchema.safeParse(raw);
346
+ if (parsed.success) {
347
+ out.push(parsed.data);
348
+ seenIds.add(parsed.data.id);
349
+ }
350
+ continue;
351
+ }
352
+ // Widget arm — stamp a public bundleUrl with the version in the
353
+ // PATH, not a `?v=` query. Module-Federation bundles import their
354
+ // fixed-name siblings (`./_stub.js`) by RELATIVE url, which never
355
+ // inherit a query — so a `?v=` busts only the entry and a CDN edge
356
+ // keeps serving a stale `_stub.js` (missing the newest widget). A
357
+ // path prefix makes every relative import inherit it → one atomic
358
+ // whole-bundle cache miss per deploy, on any CDN. `AUTH_BUNDLE_VERSION`
359
+ // is stable per hub process (busts on restart/deploy, caches within
360
+ // uptime). The `/api/addon-widgets` route strips the `v<…>/` segment.
361
+ const parsed = PublicWidgetLoginMethodSchema.safeParse({
362
+ ...raw,
363
+ bundleUrl: `/api/addon-widgets/${raw.addonId}/v${AUTH_BUNDLE_VERSION}/${raw.bundle}`,
364
+ });
365
+ if (parsed.success) {
366
+ out.push(parsed.data);
367
+ seenIds.add(parsed.data.id);
368
+ }
394
369
  }
395
370
  }
396
- return factors;
371
+ return out;
397
372
  };
373
+ /**
374
+ * Sign-in providers a signed-in user may link (D782): an enabled provider's
375
+ * redirect method that declares `linkable: true` — the contract, not its id.
376
+ */
377
+ const linkableProviders = async () => (await aggregateLoginMethods()).flatMap((m) => m.kind === 'redirect' && m.linkable === true
378
+ ? [{ provider: m.id, label: m.label, startUrl: m.startUrl }]
379
+ : []);
398
380
  return (0, trpc_middleware_js_1.trpcRouter)({
399
381
  login: credentialProcedure
400
382
  .input(zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string() }))
@@ -428,7 +410,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
428
410
  // protected endpoints (the auth middleware rejects anything
429
411
  // without the standard session shape); the same token binds
430
412
  // both the TOTP and the passkey second legs (same userId).
431
- const secondFactors = await collectSecondFactors(user.id, 'password');
413
+ const secondFactors = await collectSecondFactorsFor(user.id, 'password');
432
414
  if (secondFactors.length > 0) {
433
415
  const challengeToken = auth.signTotpChallengeToken({
434
416
  userId: user.id,
@@ -487,13 +469,13 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
487
469
  */
488
470
  loginBeginPasskey: ceremonyProcedure
489
471
  .input(zod_1.z.object({ challengeToken: zod_1.z.string() }))
490
- .output(OptionsJsonSchema)
472
+ .output(passkey_provider_js_1.OptionsJsonSchema)
491
473
  .mutation(async ({ input }) => {
492
474
  const claims = auth.verifyTotpChallengeToken(input.challengeToken);
493
475
  if (!claims) {
494
476
  throw new Error('Invalid or expired login challenge — please re-enter your password');
495
477
  }
496
- const provider = resolvePasskeyProvider(registry, moleculer);
478
+ const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
497
479
  if (!provider)
498
480
  throw new Error('Passkey authentication is not available');
499
481
  return provider.beginAuthentication({ userId: claims.userId });
@@ -513,7 +495,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
513
495
  if (!claims) {
514
496
  throw new Error('Invalid or expired login challenge — please re-enter your password');
515
497
  }
516
- const provider = resolvePasskeyProvider(registry, moleculer);
498
+ const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
517
499
  if (!provider)
518
500
  throw new Error('Passkey authentication is not available');
519
501
  const result = await provider.finishAuthentication({
@@ -539,9 +521,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
539
521
  */
540
522
  passkeyLoginBegin: ceremonyProcedure
541
523
  .input(zod_1.z.void())
542
- .output(OptionsJsonSchema)
524
+ .output(passkey_provider_js_1.OptionsJsonSchema)
543
525
  .mutation(async () => {
544
- const provider = resolvePasskeyProvider(registry, moleculer);
526
+ const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
545
527
  if (!provider)
546
528
  throw new Error('Passkey authentication is not available');
547
529
  return provider.beginDiscoverableAuthentication({});
@@ -565,7 +547,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
565
547
  .input(zod_1.z.object({ response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }))
566
548
  .output(LoginResultSchema)
567
549
  .mutation(async ({ input }) => {
568
- const provider = resolvePasskeyProvider(registry, moleculer);
550
+ const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
569
551
  if (!provider)
570
552
  throw new Error('Passkey authentication is not available');
571
553
  const result = await provider.finishDiscoverableAuthentication({
@@ -574,7 +556,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
574
556
  if (!result.verified || !result.userId) {
575
557
  throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'Passkey verification failed' });
576
558
  }
577
- const secondFactors = await collectSecondFactors(result.userId, 'passkey');
559
+ const secondFactors = await collectSecondFactorsFor(result.userId, 'passkey');
578
560
  if (secondFactors.length > 0) {
579
561
  const challenged = await findUserById(result.userId);
580
562
  const challengeToken = auth.signTotpChallengeToken({
@@ -808,22 +790,22 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
808
790
  // the `user-passkeys` cap, never an addon id.
809
791
  beginOwnPasskeyRegistration: trpc_middleware_js_1.protectedProcedure
810
792
  .input(zod_1.z.void())
811
- .output(OptionsJsonSchema)
793
+ .output(passkey_provider_js_1.OptionsJsonSchema)
812
794
  .mutation(async ({ ctx }) => {
813
795
  if (!ctx.user)
814
796
  throw new Error('Not authenticated');
815
- const provider = resolvePasskeyProvider(registry, moleculer);
797
+ const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
816
798
  if (!provider)
817
799
  throw new Error('Passkey enrollment is not available');
818
800
  return provider.beginRegistration({ userId: ctx.user.id, username: ctx.user.username });
819
801
  }),
820
802
  finishOwnPasskeyRegistration: trpc_middleware_js_1.protectedProcedure
821
803
  .input(zod_1.z.object({ response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()), label: zod_1.z.string() }))
822
- .output(FinishRegistrationSchema)
804
+ .output(passkey_provider_js_1.FinishRegistrationSchema)
823
805
  .mutation(async ({ input, ctx }) => {
824
806
  if (!ctx.user)
825
807
  throw new Error('Not authenticated');
826
- const provider = resolvePasskeyProvider(registry, moleculer);
808
+ const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
827
809
  if (!provider)
828
810
  throw new Error('Passkey enrollment is not available');
829
811
  return provider.finishRegistration({
@@ -838,7 +820,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
838
820
  .query(async ({ ctx }) => {
839
821
  if (!ctx.user)
840
822
  return [];
841
- const provider = resolvePasskeyProvider(registry, moleculer);
823
+ const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
842
824
  if (!provider)
843
825
  return [];
844
826
  try {
@@ -854,7 +836,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
854
836
  .mutation(async ({ input, ctx }) => {
855
837
  if (!ctx.user)
856
838
  throw new Error('Not authenticated');
857
- const provider = resolvePasskeyProvider(registry, moleculer);
839
+ const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
858
840
  if (!provider)
859
841
  throw new Error('Passkey management is not available');
860
842
  return provider.removePasskey({ userId: ctx.user.id, credentialId: input.credentialId });
@@ -869,11 +851,11 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
869
851
  */
870
852
  getOwnPasskeySecondFactorPreference: trpc_middleware_js_1.protectedProcedure
871
853
  .input(zod_1.z.void())
872
- .output(SecondFactorPreferenceSchema)
854
+ .output(passkey_provider_js_1.SecondFactorPreferenceSchema)
873
855
  .query(async ({ ctx }) => {
874
856
  if (!ctx.user)
875
857
  return { enabled: false };
876
- const provider = resolvePasskeyProvider(registry, moleculer);
858
+ const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
877
859
  if (!provider)
878
860
  return { enabled: false };
879
861
  try {
@@ -885,11 +867,11 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
885
867
  }),
886
868
  setOwnPasskeySecondFactorPreference: trpc_middleware_js_1.protectedProcedure
887
869
  .input(zod_1.z.object({ enabled: zod_1.z.boolean() }))
888
- .output(SetSecondFactorPreferenceSchema)
870
+ .output(passkey_provider_js_1.SetSecondFactorPreferenceSchema)
889
871
  .mutation(async ({ input, ctx }) => {
890
872
  if (!ctx.user)
891
873
  throw new Error('Not authenticated');
892
- const provider = resolvePasskeyProvider(registry, moleculer);
874
+ const provider = (0, passkey_provider_js_1.resolvePasskeyProvider)(registry, moleculer);
893
875
  if (!provider)
894
876
  throw new Error('Passkey management is not available');
895
877
  return provider.setSecondFactorPreference({
@@ -973,6 +955,74 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
973
955
  .input(zod_1.z.void())
974
956
  .output(zod_1.z.object({ success: zod_1.z.literal(true) }))
975
957
  .mutation(() => ({ success: true })),
958
+ // ── External sign-in identities (D782) ────────────────────────
959
+ //
960
+ // A signed-in user links an OIDC identity to THEIR OWN account. The
961
+ // intent is minted for `ctx.user` here and resolved by the SSO finish,
962
+ // which also requires the same user's session cookie — the user id never
963
+ // travels through the browser.
964
+ /** Sign-in providers the caller can link (redirect methods declaring `linkable`). */
965
+ listLinkableProviders: trpc_middleware_js_1.protectedProcedure
966
+ .input(zod_1.z.void())
967
+ .output(zod_1.z.array(zod_1.z.object({ provider: zod_1.z.string(), label: zod_1.z.string() })))
968
+ .query(async () => (await linkableProviders()).map(({ provider, label }) => ({ provider, label }))),
969
+ /** Start linking `provider` to the caller; the client navigates to `url`. */
970
+ beginExternalLink: trpc_middleware_js_1.protectedProcedure
971
+ .input(zod_1.z.object({ provider: zod_1.z.string().min(1) }))
972
+ .output(zod_1.z.object({ url: zod_1.z.string() }))
973
+ .mutation(async ({ ctx, input }) => {
974
+ if (!ctx.user)
975
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
976
+ assertRealUserSession(ctx.user, 'Linking a sign-in account');
977
+ const target = (await linkableProviders()).find((p) => p.provider === input.provider);
978
+ if (target === undefined) {
979
+ throw new server_1.TRPCError({
980
+ code: 'BAD_REQUEST',
981
+ message: `Sign-in provider "${input.provider}" is not available for linking`,
982
+ });
983
+ }
984
+ const handle = linkIntents.create(ctx.user.id, target.provider);
985
+ const separator = target.startUrl.includes('?') ? '&' : '?';
986
+ return { url: `${target.startUrl}${separator}link=${encodeURIComponent(handle)}` };
987
+ }),
988
+ /** The caller's linked identities; `available: false` when the provider instance is gone or off. */
989
+ listOwnExternalIdentities: trpc_middleware_js_1.protectedProcedure
990
+ .input(zod_1.z.void())
991
+ .output(zod_1.z.array(OwnExternalIdentitySchema))
992
+ .query(async ({ ctx }) => {
993
+ if (!ctx.user)
994
+ return [];
995
+ const userMgmt = registry?.getSingleton('user-management');
996
+ if (!userMgmt || typeof userMgmt.listExternalIdentities !== 'function')
997
+ return [];
998
+ const labels = new Map((await linkableProviders()).map((p) => [p.provider, p.label]));
999
+ const links = await userMgmt.listExternalIdentities({ userId: ctx.user.id });
1000
+ return links.map((link) => ({
1001
+ provider: link.provider,
1002
+ subject: link.subject,
1003
+ ...(link.label !== undefined ? { label: link.label } : {}),
1004
+ linkedAt: link.linkedAt,
1005
+ providerLabel: labels.get(link.provider) ?? null,
1006
+ available: labels.has(link.provider),
1007
+ }));
1008
+ }),
1009
+ /** Remove one of the caller's own links. */
1010
+ unlinkOwnExternalIdentity: trpc_middleware_js_1.protectedProcedure
1011
+ .input(zod_1.z.object({ provider: zod_1.z.string().min(1), subject: zod_1.z.string().min(1) }))
1012
+ .output(zod_1.z.object({ success: zod_1.z.boolean() }))
1013
+ .mutation(async ({ ctx, input }) => {
1014
+ if (!ctx.user)
1015
+ throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
1016
+ assertRealUserSession(ctx.user, 'Unlinking a sign-in account');
1017
+ const userMgmt = registry?.getSingleton('user-management');
1018
+ if (!userMgmt)
1019
+ throw new Error('user-management capability not available');
1020
+ return userMgmt.unlinkExternalIdentity({
1021
+ userId: ctx.user.id,
1022
+ provider: input.provider,
1023
+ subject: input.subject,
1024
+ });
1025
+ }),
976
1026
  /**
977
1027
  * PUBLIC — the login page's single source of pre-auth login surfaces.
978
1028
  * Aggregates the `login-method` collection: every auth addon (OIDC,
@@ -982,6 +1032,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
982
1032
  * `widget` methods via `loadRemoteBundle` — a future SSO addon plugs
983
1033
  * in with ZERO shell change.
984
1034
  *
1035
+ * A provider disabled on Identity → Authentication contributes nothing
1036
+ * (D777: that toggle is an auth addon's only enable).
1037
+ *
985
1038
  * A failing / slow provider is skipped independently (mirrors
986
1039
  * `listProviders`' old resilience) so one bad addon can't lock every
987
1040
  * login method out of the UI.
@@ -989,63 +1042,6 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
989
1042
  listLoginMethods: trpc_middleware_js_1.publicProcedure
990
1043
  .input(zod_1.z.void())
991
1044
  .output(zod_1.z.array(PublicLoginMethodSchema).readonly())
992
- .query(async () => {
993
- if (!registry)
994
- return [];
995
- const out = [];
996
- // Defense-in-depth dedupe: a routing bug in a grouped runner once
997
- // resolved every provider to the same addon (3× identical magic-link
998
- // buttons) — a duplicated contribution id must never render twice.
999
- const seenIds = new Set();
1000
- for (const provider of registry.getCollection('login-method')) {
1001
- let contributions;
1002
- try {
1003
- contributions = await provider.getLoginMethods();
1004
- }
1005
- catch {
1006
- continue;
1007
- }
1008
- for (const raw of contributions) {
1009
- if (seenIds.has(raw.id))
1010
- continue;
1011
- if (raw.kind === 'redirect') {
1012
- const parsed = types_1.RedirectLoginMethodSchema.safeParse(raw);
1013
- if (parsed.success) {
1014
- out.push(parsed.data);
1015
- seenIds.add(parsed.data.id);
1016
- }
1017
- continue;
1018
- }
1019
- if (raw.kind === 'passkey') {
1020
- // Native ceremony — passes through untouched, no server-side
1021
- // enrichment (rpId/origin are already stamped by the addon).
1022
- const parsed = types_1.PasskeyLoginMethodSchema.safeParse(raw);
1023
- if (parsed.success) {
1024
- out.push(parsed.data);
1025
- seenIds.add(parsed.data.id);
1026
- }
1027
- continue;
1028
- }
1029
- // Widget arm — stamp a public bundleUrl with the version in the
1030
- // PATH, not a `?v=` query. Module-Federation bundles import their
1031
- // fixed-name siblings (`./_stub.js`) by RELATIVE url, which never
1032
- // inherit a query — so a `?v=` busts only the entry and a CDN edge
1033
- // keeps serving a stale `_stub.js` (missing the newest widget). A
1034
- // path prefix makes every relative import inherit it → one atomic
1035
- // whole-bundle cache miss per deploy, on any CDN. `AUTH_BUNDLE_VERSION`
1036
- // is stable per hub process (busts on restart/deploy, caches within
1037
- // uptime). The `/api/addon-widgets` route strips the `v<…>/` segment.
1038
- const parsed = PublicWidgetLoginMethodSchema.safeParse({
1039
- ...raw,
1040
- bundleUrl: `/api/addon-widgets/${raw.addonId}/v${AUTH_BUNDLE_VERSION}/${raw.bundle}`,
1041
- });
1042
- if (parsed.success) {
1043
- out.push(parsed.data);
1044
- seenIds.add(parsed.data.id);
1045
- }
1046
- }
1047
- }
1048
- return out;
1049
- }),
1045
+ .query(async () => aggregateLoginMethods()),
1050
1046
  });
1051
1047
  }