@nacre.work/api 0.17.4 → 0.18.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/server.js CHANGED
@@ -3,11 +3,12 @@ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
3
3
  // Imported rather than taken from the global scope: `lib` is ES2023 with no
4
4
  // DOM, so the global URL is not typed here.
5
5
  import { URL } from 'node:url';
6
- import { logger, MetadataError, MultipartError, multipartBoundary, parseMultipart, queryAudit, parseFilters, parseMetadata, PROTECTED_RESOURCE_PATH, JWKS_PATH, AUTHORIZATION_SERVER_PATH, AUTHORIZE_PATH, CODE_TTL_MS, corsHeaders, isPreflight, preflightHeaders, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, consentRedirect, generateClientId, generateCode, redirectAllowed, verifierMatches, ADMIN_PREFIX, adminRoutes, withAuditSinks, TooBusy, } from '@nacre.work/core';
6
+ import { logger, MetadataError, MultipartError, multipartBoundary, parseMultipart, queryAudit, parseFilters, parseMetadata, PROTECTED_RESOURCE_PATH, JWKS_PATH, AUTHORIZATION_SERVER_PATH, AUTHORIZE_PATH, CODE_TTL_MS, allowedRequestHeaders, corsHeaders, isPreflight, preflightHeaders, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, consentRedirect, generateClientId, generateCode, redirectAllowed, verifierMatches, ADMIN_PREFIX, adminRoutes, withAuditSinks, TooBusy, } from '@nacre.work/core';
7
7
  import { administers, administersTenants, authenticate, rejectTenantOverride, } from './auth.js';
8
- import { badRequest, internal, notAdministeredHere, notFound, Problem } from './errors.js';
8
+ import { badRequest, internal, notAdministeredHere, notFound, Problem, tooBusy } from './errors.js';
9
9
  import { isConflict, isReplay } from './idempotency.js';
10
10
  import { limitHeaders } from './limits.js';
11
+ import { MIN_PASSWORD_LENGTH } from './recovery.js';
11
12
  import { looksLikeEmail } from './principals.js';
12
13
  import { clientSource } from './source.js';
13
14
  import { auditFormat, auditJson, readAuditQuery, toCsv, toNdjson, } from './audit-export.js';
@@ -487,6 +488,81 @@ const neverCached = (instance) => NEVER_CACHED.some((path) => instance === path
487
488
  * key, and an operator reading `KEYS nacre:rl:*` should not be reading a list
488
489
  * of who has an account here.
489
490
  */
491
+ /**
492
+ * Tell a person their second factor changed, where this deployment can.
493
+ *
494
+ * Best effort in every direction: no sender configured means no message, a
495
+ * relay that refuses is a log line, and neither stops the change that already
496
+ * happened. The address is read here rather than carried through the handler,
497
+ * because `AuthContext` holds a principal id and not an address.
498
+ *
499
+ * The two events are worth a message for the same reason a password change is:
500
+ * a factor appearing on an account, or disappearing from one, is what somebody
501
+ * taking it over does first.
502
+ */
503
+ async function notifySecurityChange(options, orgId, userId, what, requestId) {
504
+ const mailer = options.mailer;
505
+ if (mailer === undefined)
506
+ return;
507
+ try {
508
+ const address = await options.secondFactors?.emailOf(orgId, userId);
509
+ if (address === undefined || address === null)
510
+ return;
511
+ await mailer.send({
512
+ to: address,
513
+ subject: what === 'enrolled' ? 'A second factor was added to your Nacre account' : 'A second factor was removed from your Nacre account',
514
+ text: what === 'enrolled'
515
+ ? [
516
+ 'An authenticator was just added to this account.',
517
+ '',
518
+ 'If it was not you, somebody is signing in as you: change your password',
519
+ 'and tell your administrator.',
520
+ ].join('\n')
521
+ : [
522
+ 'An authenticator was just removed from this account.',
523
+ '',
524
+ 'Removing one is the first thing somebody with a stolen session does. If',
525
+ 'it was not you, change your password and tell your administrator.',
526
+ ].join('\n'),
527
+ });
528
+ }
529
+ catch (error) {
530
+ logger.warn('could not send a security notice', {
531
+ request_id: requestId,
532
+ error: String(error).slice(0, 200),
533
+ });
534
+ }
535
+ }
536
+ /**
537
+ * The one answer to a full password gate, at whichever boundary caught it.
538
+ *
539
+ * There are two, and the split is authentication: sign-in and password recovery
540
+ * are reached without a credential and run before the request path that has
541
+ * one, so a single `catch` cannot cover both. Both call this, so there is one
542
+ * status, one wording and one header however a hashing route was reached.
543
+ *
544
+ * It used to be caught beside `login()` alone, which made the claim in
545
+ * `core/passwords.ts` — "the caller answers 503" — true of sign-in and false of
546
+ * the three other routes that hash: creating a user, an administrator resetting
547
+ * somebody's password, and redeeming a recovery link each turned a loaded
548
+ * process into a `500`, which a client reports as a broken server and an
549
+ * operator investigates as a bug. A rule stated in a comment and held in one of
550
+ * four places is the shape this repository keeps closing.
551
+ *
552
+ * Logged as load rather than as a failure, and deliberately not written to the
553
+ * journal: an audit row saying a request failed is read as a defect, and
554
+ * shedding load is the design working.
555
+ *
556
+ * @returns whether it answered, so a caller reads as `if (handled) return`.
557
+ */
558
+ function handledTooBusy(res, error, instance, requestId) {
559
+ if (!(error instanceof TooBusy))
560
+ return false;
561
+ logger.warn('password gate full', { request_id: requestId, instance });
562
+ const problem = tooBusy(instance, requestId);
563
+ send(res, problem.status, problem.toJSON(), requestId, { 'retry-after': '2' });
564
+ return true;
565
+ }
490
566
  async function handleAuth(req, res, instance, requestId, options) {
491
567
  if (options.login === undefined || req.method !== 'POST') {
492
568
  const problem = notFound(instance, requestId);
@@ -557,41 +633,12 @@ async function handleAuth(req, res, instance, requestId, options) {
557
633
  return;
558
634
  }
559
635
  }
560
- let tokens;
561
- try {
562
- tokens = await options.login.login({
563
- email,
564
- password,
565
- ...(typeof organization === 'string' ? { organization } : {}),
566
- });
567
- }
568
- catch (error) {
569
- // The process is already verifying as many passwords as it will. scrypt
570
- // runs on libuv's thread pool, which is shared with DNS and file I/O, so
571
- // an unbounded login endpoint stops the *rest* of the API on a name
572
- // lookup — see the gate in core/passwords.ts.
573
- //
574
- // 503 and not 401: nothing was decided about these credentials, and
575
- // answering "not valid" to a request that was never checked is a lie the
576
- // client will act on. 503 with Retry-After is the honest one.
577
- //
578
- // Not an oracle either. It depends on how loaded the process is and not
579
- // at all on whether the account exists.
580
- if (error instanceof TooBusy) {
581
- const problem = new Problem({
582
- type: 'https://nacre.work/errors/unavailable',
583
- title: 'Service unavailable',
584
- status: 503,
585
- detail: 'Too many sign-in attempts are being processed. Try again shortly.',
586
- instance,
587
- requestId,
588
- });
589
- send(res, problem.status, problem.toJSON(), requestId, { 'retry-after': '2' });
590
- return;
591
- }
592
- throw error;
593
- }
594
- if (tokens === undefined) {
636
+ const outcome = await options.login.login({
637
+ email,
638
+ password,
639
+ ...(typeof organization === 'string' ? { organization } : {}),
640
+ });
641
+ if (outcome === undefined) {
595
642
  // Logged rather than audited, and the difference is not laziness. The
596
643
  // audit log is per-organization; an address that matches no user belongs
597
644
  // to no tenant, and giving that row an owner would put one
@@ -606,6 +653,27 @@ async function handleAuth(req, res, instance, requestId, options) {
606
653
  refuse();
607
654
  return;
608
655
  }
656
+ /*
657
+ * A correct password and a second factor to produce.
658
+ *
659
+ * Not an audit event yet, and not a `login` one whatever happens next: this
660
+ * is half of an authentication, and writing "login allow" here would put a
661
+ * successful sign-in in the journal for somebody who never produced their
662
+ * code. The event is written where the session is issued.
663
+ *
664
+ * 200 rather than 401, because nothing was refused — the client is being
665
+ * asked for the rest of what it needs, which is what `second_factor_required`
666
+ * says.
667
+ */
668
+ if (outcome.kind === 'second-factor') {
669
+ send(res, 200, {
670
+ second_factor_required: true,
671
+ challenge: outcome.challenge,
672
+ expires_in: outcome.expiresIn,
673
+ }, requestId);
674
+ return;
675
+ }
676
+ const tokens = outcome.tokens;
609
677
  // The successful one does have an organization to belong to, and it is the
610
678
  // event that answers "who has been in here". Awaited: a lost audit event is
611
679
  // worse than a slow response.
@@ -621,6 +689,149 @@ async function handleAuth(req, res, instance, requestId, options) {
621
689
  send(res, 200, tokenJson(tokens), requestId);
622
690
  return;
623
691
  }
692
+ /*
693
+ * What this installation offers before anybody has signed in.
694
+ *
695
+ * One boolean, and it exists so the console can leave the recovery link
696
+ * **off the screen** where no sender is configured rather than showing a
697
+ * control that answers 404. A screen offering what the server refuses is a
698
+ * defect this console has already shipped once.
699
+ *
700
+ * It tells a stranger that this deployment can send email, which is what they
701
+ * would learn by pressing the link anyway. It says nothing about any address.
702
+ */
703
+ if (instance === '/v1/auth/methods') {
704
+ send(res, 200, { password_reset: options.recovery !== undefined }, requestId);
705
+ return;
706
+ }
707
+ /*
708
+ * Ask for a recovery link.
709
+ *
710
+ * **`204` whatever happened**, including for an address with no account, an
711
+ * address in two organizations, a disabled account and a send that failed.
712
+ * Anything else makes this the account-enumeration oracle the sign-in path is
713
+ * careful not to be — and this one needs no credential at all.
714
+ */
715
+ if (instance === '/v1/auth/password-reset' && options.recovery !== undefined) {
716
+ const email = (body ?? {});
717
+ if (typeof email.email !== 'string') {
718
+ const problem = badRequest(instance, requestId, "'email' is required.");
719
+ send(res, problem.status, problem.toJSON(), requestId);
720
+ return;
721
+ }
722
+ // The same two buckets sign-in has, and for the same reasons: the address
723
+ // one defends a mailbox from being flooded on somebody's behalf, and the
724
+ // source one bounds the sweep across a directory that never repeats an
725
+ // address.
726
+ if (options.limits !== undefined && options.limitPolicies?.login !== undefined) {
727
+ const subject = createHash('sha256').update(email.email.trim().toLowerCase()).digest('hex').slice(0, 32);
728
+ const source = clientSource(req, { trustProxy: options.trustProxy ?? 0 });
729
+ const decisions = [await options.limits.check(subject, 'login')];
730
+ if (source !== undefined && options.limitPolicies.login_source !== undefined) {
731
+ decisions.push(await options.limits.check(`src:${source}`, 'login_source'));
732
+ }
733
+ if (decisions.some((d) => !d.allowed)) {
734
+ // Still 204. A 429 here would say "this address is being asked about",
735
+ // which is the thing the endpoint refuses to say.
736
+ send(res, 204, null, requestId);
737
+ return;
738
+ }
739
+ }
740
+ // Awaited, but its failure is swallowed: the answer is the same either way,
741
+ // and an unhandled rejection would take the process down over a relay being
742
+ // briefly unreachable.
743
+ await options.recovery.request(email.email).catch((error) => {
744
+ logger.warn('could not start a password recovery', {
745
+ request_id: requestId,
746
+ error: String(error).slice(0, 200),
747
+ });
748
+ });
749
+ send(res, 204, null, requestId);
750
+ return;
751
+ }
752
+ if (instance === '/v1/auth/password-reset/confirm' && options.recovery !== undefined) {
753
+ const { token, password } = (body ?? {});
754
+ if (typeof token !== 'string' || typeof password !== 'string') {
755
+ const problem = badRequest(instance, requestId, "'token' and 'password' are required.");
756
+ send(res, problem.status, problem.toJSON(), requestId);
757
+ return;
758
+ }
759
+ const outcome = await options.recovery.redeem(token, password);
760
+ if (outcome === 'too-short') {
761
+ // The one thing this endpoint does say, because it is about what the
762
+ // caller sent rather than about what exists. A refusal with no reason
763
+ // here is a person retyping a password that will never be accepted.
764
+ const problem = badRequest(instance, requestId, `A password is at least ${String(MIN_PASSWORD_LENGTH)} characters. Length is the whole rule — there is no requirement about digits or symbols.`);
765
+ send(res, problem.status, problem.toJSON(), requestId);
766
+ return;
767
+ }
768
+ if (outcome === 'refused') {
769
+ // One answer for a link that never existed, one already used, one that
770
+ // expired, and an account disabled since it was sent.
771
+ refuse();
772
+ return;
773
+ }
774
+ send(res, 204, null, requestId);
775
+ return;
776
+ }
777
+ /*
778
+ * The second half of a sign-in.
779
+ *
780
+ * Rate limited on the same buckets as `/v1/auth/login`, because otherwise the
781
+ * limit is a limit on passwords and not on sessions: an attacker holding a
782
+ * password spends one login and then guesses six digits here without ever
783
+ * meeting a bucket again. The per-factor lock in Postgres is the bound that
784
+ * survives a Redis restart; this one is the bound that costs an attacker
785
+ * their source.
786
+ */
787
+ if (instance === '/v1/auth/second-factor') {
788
+ const { challenge, code } = (body ?? {});
789
+ if (typeof challenge !== 'string' || typeof code !== 'string') {
790
+ const problem = badRequest(instance, requestId, "'challenge' and 'code' are required.");
791
+ send(res, problem.status, problem.toJSON(), requestId);
792
+ return;
793
+ }
794
+ if (options.limits !== undefined && options.limitPolicies?.login !== undefined) {
795
+ const source = clientSource(req, { trustProxy: options.trustProxy ?? 0 });
796
+ if (source !== undefined && options.limitPolicies.login_source !== undefined) {
797
+ const decision = await options.limits.check(`src:${source}`, 'login_source');
798
+ if (!decision.allowed) {
799
+ const problem = new Problem({
800
+ type: 'https://nacre.work/errors/rate-limited',
801
+ title: 'Too many requests',
802
+ status: 429,
803
+ detail: `Too many sign-in attempts. Try again in ${decision.reset} seconds.`,
804
+ instance,
805
+ requestId,
806
+ });
807
+ send(res, problem.status, problem.toJSON(), requestId);
808
+ return;
809
+ }
810
+ }
811
+ }
812
+ const tokens = await options.login.completeSecondFactor(challenge, code);
813
+ if (tokens === undefined) {
814
+ // One refusal for an expired challenge, a forged one, a wrong code and a
815
+ // disabled account alike. Which of the four it was is nothing a client
816
+ // needs and something an attacker would use.
817
+ logger.warn('second factor refused', { request_id: requestId });
818
+ refuse();
819
+ return;
820
+ }
821
+ await options.audit.write({
822
+ orgId: tokens.orgId,
823
+ actor: `user:${tokens.userId}`,
824
+ action: 'login',
825
+ result: 'allow',
826
+ target: { user_id: tokens.userId },
827
+ // The journal says which door, because "signed in with a second factor"
828
+ // and "signed in with a password" are different facts to an investigator.
829
+ detail: { second_factor: true },
830
+ requestId,
831
+ });
832
+ send(res, 200, tokenJson(tokens), requestId);
833
+ return;
834
+ }
624
835
  const token = body?.refresh_token;
625
836
  if (typeof token !== 'string' || token === '') {
626
837
  const problem = badRequest(instance, requestId, "'refresh_token' is required.");
@@ -705,10 +916,15 @@ async function handle(req, res, options) {
705
916
  res.writeHead(204, preflightHeaders({
706
917
  origin,
707
918
  methods: 'GET, POST, PATCH, PUT, DELETE, OPTIONS',
708
- // What this API reads. `idempotency-key` is the one the MCP transport
709
- // has never heard of, and a browser that may not send it cannot make
710
- // the retry-safe call the contract asks for.
711
- headers: 'authorization, content-type, accept, idempotency-key, if-none-match',
919
+ // What this API reads *on top of* what every MCP client sends. That
920
+ // shared set is not optional here: this API serves the two `/.well-known`
921
+ // documents a browser MCP client reads, and the SDK puts
922
+ // `mcp-protocol-version` on those requests too.
923
+ //
924
+ // `idempotency-key` is the one the MCP transport has never heard of, and
925
+ // a browser that may not send it cannot make the retry-safe call the
926
+ // contract asks for.
927
+ headers: allowedRequestHeaders(['idempotency-key', 'if-none-match']),
712
928
  }));
713
929
  res.end();
714
930
  return;
@@ -844,7 +1060,22 @@ async function handle(req, res, options) {
844
1060
  // different way — see login.ts. The organization in the issued token comes
845
1061
  // from the row that authenticated, never from the request.
846
1062
  if (instance.startsWith('/v1/auth/')) {
847
- await handleAuth(req, res, instance, requestId, options);
1063
+ try {
1064
+ await handleAuth(req, res, instance, requestId, options);
1065
+ }
1066
+ catch (error) {
1067
+ // The second of this file's two error boundaries, and the split is
1068
+ // authentication: everything below this line has a credential and is
1069
+ // covered by the catch at the bottom of this function, while sign-in and
1070
+ // password recovery are reached without one and cannot be.
1071
+ //
1072
+ // Both defer to `handledTooBusy` rather than answering, so there is one
1073
+ // 503 and one wording however a hashing route was reached. Anything else
1074
+ // is rethrown to the handler `createApi` installs.
1075
+ if (handledTooBusy(res, error, instance, requestId))
1076
+ return;
1077
+ throw error;
1078
+ }
848
1079
  return;
849
1080
  }
850
1081
  // A path this server does not route is `404`, and it says so **before**
@@ -1757,6 +1988,266 @@ async function handle(req, res, options) {
1757
1988
  }, requestId);
1758
1989
  return;
1759
1990
  }
1991
+ /*
1992
+ * The caller's own password.
1993
+ *
1994
+ * Under `/v1/me` for the same reason the second factor is: this is a person
1995
+ * acting on themselves, and there is no id in the path for anybody to put
1996
+ * somebody else's in. `POST /v1/users/{id}/password` beside it is the other
1997
+ * thing — an administrator issuing a generated password to a colleague who
1998
+ * lost theirs — and the two are deliberately not one endpoint with a
1999
+ * branch.
2000
+ *
2001
+ * A service account is refused: a key has no password, and `nacre_sk_` is
2002
+ * rotated by minting another. A delegation is refused because changing how
2003
+ * somebody signs in is not something a third party acting for them was
2004
+ * approved to do — the same line the second factor draws.
2005
+ */
2006
+ if (instance === '/v1/me/password') {
2007
+ if (req.method !== 'POST' || options.login === undefined) {
2008
+ const problem = notFound(instance, requestId);
2009
+ send(res, problem.status, problem.toJSON(), requestId);
2010
+ return;
2011
+ }
2012
+ if (auth.principal.type !== 'user' || auth.delegation !== undefined) {
2013
+ const problem = notFound(instance, requestId);
2014
+ send(res, problem.status, problem.toJSON(), requestId);
2015
+ return;
2016
+ }
2017
+ const { current_password: currentPassword, new_password: newPassword } = (body ?? {});
2018
+ if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
2019
+ const problem = badRequest(instance, requestId, "'current_password' and 'new_password' are required.");
2020
+ send(res, problem.status, problem.toJSON(), requestId);
2021
+ return;
2022
+ }
2023
+ if (newPassword.length < MIN_PASSWORD_LENGTH) {
2024
+ const problem = badRequest(instance, requestId, `A password is at least ${String(MIN_PASSWORD_LENGTH)} characters. Length is the whole rule — there is no requirement about digits or symbols.`);
2025
+ send(res, problem.status, problem.toJSON(), requestId);
2026
+ return;
2027
+ }
2028
+ const outcome = await options.login.changePassword(auth.orgId, auth.principal.id, currentPassword, newPassword);
2029
+ if (typeof outcome === 'string') {
2030
+ await options.audit.write({
2031
+ orgId: auth.orgId,
2032
+ actor: `user:${auth.principal.id}`,
2033
+ action: 'password.change',
2034
+ result: 'deny',
2035
+ target: { user_id: auth.principal.id },
2036
+ detail: { reason: outcome },
2037
+ requestId,
2038
+ });
2039
+ /*
2040
+ * `403`, and the choice is about clients rather than about semantics.
2041
+ *
2042
+ * `401` is the obvious status for a credential that did not check out,
2043
+ * and it is the wrong one here: on an authenticated route `401` means
2044
+ * "your session is over", and every client in this repository renews on
2045
+ * it and replays. A wrong current password would therefore spend a
2046
+ * refresh token, replay, fail again, and reach the person as two
2047
+ * failures with a renewal between them — for something they can fix by
2048
+ * retyping.
2049
+ *
2050
+ * Not `404` either. The caller is looking straight at their own
2051
+ * account, so there is nothing invisible for invariant 4 to protect —
2052
+ * the same reasoning as the `platform_admin` refusal on `/v1/users`.
2053
+ *
2054
+ * One wording for a wrong password and for the guard cases beside it,
2055
+ * because the guard cases are unreachable by anyone holding a token
2056
+ * this row issued and telling them apart would say nothing true.
2057
+ */
2058
+ const problem = new Problem({
2059
+ type: 'https://nacre.work/errors/forbidden',
2060
+ title: 'Forbidden',
2061
+ status: 403,
2062
+ detail: 'The current password is not correct.',
2063
+ instance,
2064
+ requestId,
2065
+ });
2066
+ send(res, problem.status, problem.toJSON(), requestId);
2067
+ return;
2068
+ }
2069
+ await options.audit.write({
2070
+ orgId: auth.orgId,
2071
+ actor: `user:${auth.principal.id}`,
2072
+ action: 'password.change',
2073
+ result: 'allow',
2074
+ target: { user_id: auth.principal.id },
2075
+ detail: {},
2076
+ requestId,
2077
+ });
2078
+ // A notice, not a confirmation. Somebody who has taken an account changes
2079
+ // the password to keep it, so the person who did not do this is the one
2080
+ // who needs to know. Dropped rather than raised: the password *is*
2081
+ // changed, and refusing the request over a notice would be worse than a
2082
+ // notice that did not arrive.
2083
+ void options.mailer
2084
+ ?.send({
2085
+ to: outcome.email,
2086
+ subject: 'Your Nacre password was changed',
2087
+ text: [
2088
+ 'The password for this account was just changed from a signed-in session.',
2089
+ '',
2090
+ 'Every other session was signed out. Any second factor on the account is',
2091
+ 'untouched and is still required.',
2092
+ '',
2093
+ 'If this was not you, somebody knew your password — reset it from the',
2094
+ 'sign-in screen and tell your administrator.',
2095
+ ].join('\n'),
2096
+ })
2097
+ .catch(() => undefined);
2098
+ // The pair that replaces the sessions this just ended. Answering 204 and
2099
+ // leaving the caller holding a revoked refresh token would sign a person
2100
+ // out of the browser they changed their password in, which reads as a
2101
+ // failure.
2102
+ send(res, 200, {
2103
+ access_token: outcome.tokens.accessToken,
2104
+ token_type: 'Bearer',
2105
+ expires_in: outcome.tokens.expiresIn,
2106
+ refresh_token: outcome.tokens.refreshToken,
2107
+ }, requestId);
2108
+ return;
2109
+ }
2110
+ /*
2111
+ * The caller's own second factor.
2112
+ *
2113
+ * Under `/v1/me` and never `/v1/users/{id}`, which is the security property
2114
+ * rather than a URL preference: an administrator resets somebody's password
2115
+ * and must not be able to enrol, read or remove their second factor —
2116
+ * doing so would make the factor a thing the account's administrator holds,
2117
+ * and the whole point is that it is a thing the *person* holds.
2118
+ *
2119
+ * A service account and a delegation are refused. A key is not a person and
2120
+ * has nobody to carry an authenticator; a delegation is a third party
2121
+ * acting for somebody, and letting it change how that somebody signs in
2122
+ * would be an escalation out of what was approved.
2123
+ */
2124
+ if (instance === '/v1/me/second-factor' || instance.startsWith('/v1/me/second-factor/')) {
2125
+ if (options.secondFactors === undefined || !options.secondFactors.available) {
2126
+ // No key configured, so the feature is absent rather than broken. 404
2127
+ // and not 501: from outside, a route this installation does not serve
2128
+ // and one it has not been given a key for are the same thing.
2129
+ const problem = notFound(instance, requestId);
2130
+ send(res, problem.status, problem.toJSON(), requestId);
2131
+ return;
2132
+ }
2133
+ if (auth.principal.type !== 'user' || auth.delegation !== undefined) {
2134
+ const problem = notFound(instance, requestId);
2135
+ send(res, problem.status, problem.toJSON(), requestId);
2136
+ return;
2137
+ }
2138
+ const factors = options.secondFactors;
2139
+ const userId = auth.principal.id;
2140
+ const rest = instance.slice('/v1/me/second-factor'.length);
2141
+ if (rest === '' && req.method === 'GET') {
2142
+ const [items, left] = await Promise.all([
2143
+ factors.list(auth.orgId, userId),
2144
+ factors.recoveryCodesLeft(auth.orgId, userId),
2145
+ ]);
2146
+ send(res, 200, {
2147
+ items: items.map((f) => ({
2148
+ id: f.id,
2149
+ kind: f.kind,
2150
+ label: f.label,
2151
+ created_at: f.createdAt.toISOString(),
2152
+ last_used_at: f.lastUsedAt?.toISOString() ?? null,
2153
+ })),
2154
+ recovery_codes_left: left,
2155
+ }, requestId);
2156
+ return;
2157
+ }
2158
+ if (rest === '' && req.method === 'POST') {
2159
+ const label = body?.label;
2160
+ const named = typeof label === 'string' && label.trim() !== '' ? label.trim().slice(0, 60) : 'Authenticator';
2161
+ const begun = await factors.begin(auth.orgId, userId, named);
2162
+ if (begun === undefined) {
2163
+ const problem = notFound(instance, requestId);
2164
+ send(res, problem.status, problem.toJSON(), requestId);
2165
+ return;
2166
+ }
2167
+ // The secret in the response and nowhere else: this is the one moment
2168
+ // it exists outside the sealed column, exactly as a generated password
2169
+ // is.
2170
+ send(res, 201, { id: begun.id, secret: begun.secret, otpauth_url: begun.otpauthUrl, label: named }, requestId);
2171
+ return;
2172
+ }
2173
+ if (rest.endsWith('/confirm') && req.method === 'POST') {
2174
+ const id = rest.slice(1, -'/confirm'.length);
2175
+ const code = body?.code;
2176
+ if (!UUID_SHAPE.test(id) || typeof code !== 'string') {
2177
+ const problem = badRequest(instance, requestId, "'code' is required.");
2178
+ send(res, problem.status, problem.toJSON(), requestId);
2179
+ return;
2180
+ }
2181
+ const codes = await factors.confirm(auth.orgId, userId, id, code);
2182
+ if (codes === undefined) {
2183
+ // One refusal for a wrong code and for an enrolment that is not
2184
+ // there. Telling them apart would say whether a given id exists.
2185
+ const problem = notFound(instance, requestId);
2186
+ send(res, problem.status, problem.toJSON(), requestId);
2187
+ return;
2188
+ }
2189
+ await options.audit.write({
2190
+ orgId: auth.orgId,
2191
+ actor: `user:${userId}`,
2192
+ action: 'second_factor.enrol',
2193
+ result: 'allow',
2194
+ target: { user_id: userId },
2195
+ detail: {},
2196
+ requestId,
2197
+ });
2198
+ // A notice, where the deployment can send one. The person who did not
2199
+ // do this is the one who needs to know, and a second factor appearing
2200
+ // on an account is exactly what somebody taking it over would do.
2201
+ // Dropped rather than raised: the enrolment happened.
2202
+ void notifySecurityChange(options, auth.orgId, userId, 'enrolled', requestId);
2203
+ // Printed once. A second call returns an empty list rather than new
2204
+ // codes, because reissuing them here would invalidate the set somebody
2205
+ // has already written down.
2206
+ send(res, 200, { recovery_codes: codes }, requestId);
2207
+ return;
2208
+ }
2209
+ if (rest !== '' && !rest.includes('/') && req.method === 'DELETE') {
2210
+ const id = rest.slice(1);
2211
+ const code = body?.code;
2212
+ if (!UUID_SHAPE.test(id) || typeof code !== 'string') {
2213
+ const problem = badRequest(instance, requestId, "'code' is required to remove a second factor.");
2214
+ send(res, problem.status, problem.toJSON(), requestId);
2215
+ return;
2216
+ }
2217
+ /*
2218
+ * A current code to take one off, and that is the whole reason this
2219
+ * endpoint takes a body at all. Removing the second factor is the first
2220
+ * thing somebody with a stolen session does, and a session is exactly
2221
+ * what the factor exists to be more than.
2222
+ */
2223
+ if (!(await factors.verify(auth.orgId, userId, code))) {
2224
+ const problem = notFound(instance, requestId);
2225
+ send(res, problem.status, problem.toJSON(), requestId);
2226
+ return;
2227
+ }
2228
+ const removed = await factors.remove(auth.orgId, userId, id);
2229
+ if (!removed) {
2230
+ const problem = notFound(instance, requestId);
2231
+ send(res, problem.status, problem.toJSON(), requestId);
2232
+ return;
2233
+ }
2234
+ await options.audit.write({
2235
+ orgId: auth.orgId,
2236
+ actor: `user:${userId}`,
2237
+ action: 'second_factor.remove',
2238
+ result: 'allow',
2239
+ target: { user_id: userId },
2240
+ detail: {},
2241
+ requestId,
2242
+ });
2243
+ void notifySecurityChange(options, auth.orgId, userId, 'removed', requestId);
2244
+ send(res, 204, null, requestId);
2245
+ return;
2246
+ }
2247
+ const problem = notFound(instance, requestId);
2248
+ send(res, problem.status, problem.toJSON(), requestId);
2249
+ return;
2250
+ }
1760
2251
  if (instance === '/v1/embedding-providers' && options.embeddingProviders !== undefined) {
1761
2252
  if (req.method === 'GET') {
1762
2253
  // Not paged. A deployment has a handful of these — one per model it
@@ -3275,6 +3766,11 @@ async function handle(req, res, options) {
3275
3766
  send(res, problem.status, problem.toJSON(), requestId);
3276
3767
  }
3277
3768
  catch (error) {
3769
+ // Load, not a failure — and deliberately before the log and the journal
3770
+ // below, because an audit row saying a request failed is read as a defect
3771
+ // and shedding load is the design working.
3772
+ if (handledTooBusy(res, error, instance, requestId))
3773
+ return;
3278
3774
  // The 500 body says "whatever went wrong is in the journal under this
3279
3775
  // request_id", and until this line nothing wrote it there: the error was
3280
3776
  // discarded by a bare `catch`, and the audit row carried an empty detail.