toiljs 0.0.124 → 0.0.126

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.124",
4
+ "version": "0.0.126",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -154,7 +154,7 @@
154
154
  "picocolors": "^1.1.1",
155
155
  "semver": "^7.8.5",
156
156
  "sharp": "^0.35.5",
157
- "toilscript": ">=0.1.63",
157
+ "toilscript": ">=0.1.65",
158
158
  "vite": "^8.3.4",
159
159
  "vite-imagetools": "^12.0.1",
160
160
  "vite-plugin-node-polyfills": "^0.28.0"
@@ -163,7 +163,7 @@
163
163
  "prettier": ">=3.0.0",
164
164
  "react": "^19.3.0",
165
165
  "react-dom": "^19.3.0",
166
- "toilscript": ">=0.1.63",
166
+ "toilscript": ">=0.1.65",
167
167
  "vitest": "^5.0.3",
168
168
  "@vitest/browser-webdriverio": "^5.0.0",
169
169
  "@vitest/coverage-v8": "^5.0.3",
@@ -439,8 +439,10 @@ class EmailKey {
439
439
  @data
440
440
  class EmailOwner {
441
441
  username: string = '';
442
- constructor(username: string = '') {
442
+ accountId: Uint8Array = new Uint8Array(0);
443
+ constructor(username: string = '', accountId: Uint8Array = new Uint8Array(0)) {
443
444
  this.username = username;
445
+ this.accountId = accountId;
444
446
  }
445
447
  }
446
448
 
@@ -458,6 +460,7 @@ class TokenId {
458
460
  class TokenRec {
459
461
  username: string = '';
460
462
  exp: u64 = 0;
463
+ accountId: Uint8Array = new Uint8Array(0);
461
464
  }
462
465
 
463
466
  @data
@@ -492,6 +495,11 @@ class AuthAccount {
492
495
  // Append-only: the STABLE ToilUserId, derived once at registration. Reset rotates
493
496
  // publicKey but leaves this, so the id is stable across a reset.
494
497
  toilUserId: Uint8Array = new Uint8Array(0);
498
+ // Account incarnation: never reused, even when username/password are reused.
499
+ accountId: Uint8Array = new Uint8Array(0);
500
+ deleting: bool = false;
501
+ registering: bool = false;
502
+ legacyFactors: bool = false;
495
503
  }
496
504
 
497
505
  @data
@@ -503,6 +511,7 @@ class Challenge {
503
511
  exp: u64 = 0;
504
512
  sessionEpoch: u64 = 0;
505
513
  sessionVersion: u8 = 0;
514
+ accountId: Uint8Array = new Uint8Array(0);
506
515
  }
507
516
 
508
517
  /** The lookup key for a login 2FA challenge: 16 random bytes minted per login.
@@ -537,11 +546,30 @@ class TwoFaChallenge {
537
546
  sessionEpoch: u64 = 0;
538
547
  sessionVersion: u8 = 0;
539
548
  settingsRevision: u64 = 0;
549
+ accountId: Uint8Array = new Uint8Array(0);
550
+ }
551
+
552
+ @data
553
+ class DeletedAccountKey {
554
+ host: string = '';
555
+ incarnation: string = '';
556
+ constructor(host: string = '', incarnation: string = '') {
557
+ this.host = host;
558
+ this.incarnation = incarnation;
559
+ }
560
+ }
561
+
562
+ @data
563
+ class DeletedAccountMarker {
564
+ deleted: bool = true;
540
565
  }
541
566
 
542
567
  @database
543
568
  class AuthDb {
544
569
  @collection static accounts: Documents<Username, AuthAccount>;
570
+ // Permanent opaque incarnation guards prevent a late replica/snapshot replay
571
+ // of an old account row from enabling that deleted account again.
572
+ @collection static deletedAccounts: Documents<DeletedAccountKey, DeletedAccountMarker>;
545
573
  @collection static emails: Documents<EmailKey, EmailOwner>; // email -> username (uniqueness index)
546
574
  @collection static challenges: Documents<ChallengeId, Challenge>;
547
575
  @collection static confirmTokens: Documents<TokenId, TokenRec>;
@@ -623,36 +651,51 @@ class Auth {
623
651
  // Whether login is then BLOCKED is a separate check (requireConfirmation,
624
652
  // used in login/finish), so a domain can verify-at-register without gating.
625
653
  const mustConfirm = sendConfirmationEmail();
626
- const a = new AuthAccount();
627
- a.username = username;
628
- a.email = email;
629
- a.emailConfirmed = !mustConfirm; // auto-confirm when confirmation is off
630
- a.salt = deriveSalt(h, username);
631
- a.publicKey = pk;
632
- a.memKiB = MEM_KIB;
633
- a.iterations = ITERS;
634
- a.parallelism = PAR;
635
- // Derive + persist the stable id once. Tenant-scoped, and unique WITHOUT a
636
- // separate index: the framed derive maps distinct (username, domain) to
637
- // distinct ids, and username is unique per realm (accounts.create below), so
638
- // two accounts can never share an id.
639
- a.toilUserId = ToilUserId.derive(pk, username, authDomain(ctx)).toBytes();
640
- // create-if-absent IS the authoritative, race-safe uniqueness guard
641
- // (serialized at the key's home): a duplicate username loses here and gets the
642
- // distinguishable ST_TAKEN. No pre-`exists` read needed -- the create result
643
- // is the answer.
644
- if (!AuthDb.accounts.create(new Username(h, username), a)) {
645
- return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
654
+ const accountKey = new Username(h, username);
655
+ let abandoned = AuthDb.accounts.getCurrentSnapshot(accountKey);
656
+ if (abandoned != null && !abandoned.value.deleting && this.wasDeleted(h, abandoned.value)) {
657
+ // A stale account row may have arrived after its deletion. The
658
+ // permanent incarnation guard authorizes finishing its cleanup again.
659
+ abandoned.value.deleting = true;
660
+ if (!AuthDb.accounts.compareExchangeSnapshot(accountKey, abandoned, abandoned.value)) return fail();
661
+ abandoned = new DocumentSnapshot<AuthAccount>(abandoned.value, abandoned.value.encode());
646
662
  }
647
- // Reserve the email -> username index (email uniqueness + reset-by-email). A
648
- // duplicate email loses here; roll back the account so nothing is orphaned.
649
- if (!AuthDb.emails.create(new EmailKey(h, email), new EmailOwner(username))) {
650
- AuthDb.accounts.delete(new Username(h, username));
663
+ if (abandoned != null && abandoned.value.deleting) this.finishDeletion(ctx, accountKey, abandoned);
664
+ let a: AuthAccount;
665
+ let registering: DocumentSnapshot<AuthAccount>;
666
+ if (abandoned != null && abandoned.value.registering && abandoned.value.email == email &&
667
+ ctEqual(abandoned.value.publicKey, pk)) {
668
+ // The verified password proof authorizes resuming this exact pending
669
+ // registration after a crash between account and email reservation.
670
+ a = abandoned.value;
671
+ registering = abandoned;
672
+ } else {
673
+ a = new AuthAccount();
674
+ a.accountId = randomBytes(32);
675
+ a.registering = true;
676
+ a.username = username;
677
+ a.email = email;
678
+ a.emailConfirmed = !mustConfirm;
679
+ a.salt = deriveSalt(h, username);
680
+ a.publicKey = pk;
681
+ a.memKiB = MEM_KIB;
682
+ a.iterations = ITERS;
683
+ a.parallelism = PAR;
684
+ a.toilUserId = ToilUserId.derive(pk, username, authDomain(ctx)).toBytes();
685
+ if (!AuthDb.accounts.compareExchange(accountKey, null, a)) {
686
+ return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
687
+ }
688
+ registering = new DocumentSnapshot<AuthAccount>(a, a.encode());
689
+ }
690
+ if (!this.reserveEmail(new EmailKey(h, email), a)) {
691
+ AuthDb.accounts.compareDeleteSnapshot(accountKey, registering);
651
692
  return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
652
693
  }
653
694
 
695
+ a.registering = false;
696
+ if (!AuthDb.accounts.compareExchangeSnapshot(accountKey, registering, a)) return fail();
654
697
  if (mustConfirm) {
655
- this.issueConfirmation(h, ctx, username, email);
698
+ this.issueConfirmation(h, ctx, username, email, a);
656
699
  }
657
700
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
658
701
  }
@@ -673,11 +716,14 @@ class Auth {
673
716
  const rec = AuthDb.confirmTokens.getDelete(tokenId(h, raw));
674
717
  if (rec == null) return fail();
675
718
  if (nowSecs() >= rec.exp) return fail();
676
- const acct = AuthDb.accounts.get(new Username(h, rec.username));
677
- if (acct == null) return fail();
719
+ const key = new Username(h, rec.username);
720
+ const observed = AuthDb.accounts.getCurrentSnapshot(key);
721
+ if (observed == null) return fail();
722
+ const acct = observed.value;
723
+ if (acct.deleting || acct.registering || this.wasDeleted(h, acct) || !ctEqual(rec.accountId, this.incarnation(acct))) return fail();
678
724
  if (!acct.emailConfirmed) {
679
725
  acct.emailConfirmed = true;
680
- AuthDb.accounts.patch(new Username(h, rec.username), acct);
726
+ if (!AuthDb.accounts.compareExchangeSnapshot(key, observed, acct)) return fail();
681
727
  }
682
728
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
683
729
  }
@@ -694,9 +740,9 @@ class Auth {
694
740
  const h = realm(ctx);
695
741
  const owner = AuthDb.emails.get(new EmailKey(h, email));
696
742
  if (owner != null) {
697
- const acct = AuthDb.accounts.get(new Username(h, owner.username));
698
- if (acct != null && !acct.emailConfirmed) {
699
- this.issueConfirmation(h, ctx, owner.username, email);
743
+ const acct = this.account(new Username(h, owner.username));
744
+ if (acct != null && acct.email == email && !acct.emailConfirmed) {
745
+ this.issueConfirmation(h, ctx, owner.username, email, acct);
700
746
  }
701
747
  }
702
748
  return ackOk();
@@ -719,7 +765,7 @@ class Auth {
719
765
  if (evaluated.length != AuthService.OPRF_ELEMENT_LEN) return fail();
720
766
 
721
767
  const h = realm(ctx);
722
- const known = AuthDb.accounts.exists(new Username(h, username));
768
+ const known = this.account(new Username(h, username));
723
769
  const cid = randomBytes(16);
724
770
  const nonce = randomBytes(32);
725
771
  const iat = nowSecs();
@@ -728,16 +774,17 @@ class Auth {
728
774
  // Persist only for a real account; the response is identical either way,
729
775
  // and login/finish for an unknown user fails generically at consume. The
730
776
  // challenge key folds in `h`, so a challenge is bound to the login domain.
731
- if (known) {
777
+ if (known != null) {
732
778
  const c = new Challenge();
733
779
  c.cid = cid;
734
780
  c.username = username;
735
781
  c.nonce = nonce;
736
782
  c.iat = iat;
737
783
  c.exp = exp;
738
- const account = AuthDb.accounts.get(new Username(h, username))!;
784
+ const account = known;
739
785
  c.sessionEpoch = SessionEpochs.current(__toilEncodeAuthUser(this.stableUserId(ctx, h, username, account), username));
740
786
  c.sessionVersion = 2;
787
+ c.accountId = this.incarnation(account);
741
788
  AuthDb.challenges.create(new ChallengeId(h, cid), c);
742
789
  }
743
790
 
@@ -777,8 +824,8 @@ class Auth {
777
824
 
778
825
  // 2. Rebuild the message from OUR stored values + the client's ct (and
779
826
  // the bound params + server key id), load the account key, verify.
780
- const acct = AuthDb.accounts.get(new Username(h, ch.username));
781
- if (acct == null) return fail();
827
+ const acct = this.account(new Username(h, ch.username));
828
+ if (acct == null || !ctEqual(ch.accountId, this.incarnation(acct))) return fail();
782
829
  const message = AuthService.buildLoginMessage(
783
830
  ch.username,
784
831
  aud(),
@@ -820,7 +867,7 @@ class Auth {
820
867
  // never resetTokens/confirmTokens). This runs AFTER the signature
821
868
  // verify above, so it is not an oracle, and the login `Challenge` was
822
869
  // already consumed (getDelete) at the top of this handler.
823
- const factorMethod = FactorRepository.read(h, ch.username, acct.twoFactorMethod).method;
870
+ const factorMethod = FactorRepository.read(h, this.factorName(acct), acct.twoFactorMethod).method;
824
871
  if (factorMethod != TWOFA_NONE) {
825
872
  const raw = randomBytes(16); // the twoFaId the client echoes to /2fa/verify
826
873
  const codeHash = this.twoFaDeliver(
@@ -834,6 +881,7 @@ class Auth {
834
881
  if (codeHash.length == 0) return fail(); // unsupported/misconfigured method
835
882
  const c2 = new TwoFaChallenge();
836
883
  c2.username = ch.username;
884
+ c2.accountId = ch.accountId;
837
885
  c2.method = factorMethod;
838
886
  c2.codeHash = codeHash;
839
887
  c2.exp = nowSecs() + TWOFA_TTL_SECS;
@@ -879,19 +927,21 @@ class Auth {
879
927
  if (!r.ok) return fail();
880
928
  const h = realm(ctx);
881
929
  const owner = AuthDb.emails.get(new EmailKey(h, email));
882
- if (owner != null && AuthDb.accounts.exists(new Username(h, owner.username))) {
930
+ const resetAccount: AuthAccount | null = owner == null ? null : this.account(new Username(h, owner.username));
931
+ if (owner != null && resetAccount != null && resetAccount.email == email) {
883
932
  // Invalidate this user's previous reset token (bound to one outstanding).
884
- const prev = AuthDb.resetTokenOf.getDelete(new Username(h, owner.username));
933
+ const prev = AuthDb.resetTokenOf.getDelete(this.secondaryKey(h, resetAccount));
885
934
  if (prev != null) AuthDb.resetTokens.delete(prev);
886
935
  const raw = mintToken();
887
936
  const tid = tokenId(h, raw); // realm-bound: unredeemable on another domain
888
937
  const rec = new TokenRec();
889
938
  rec.username = owner.username;
939
+ rec.accountId = this.incarnation(resetAccount);
890
940
  rec.exp = nowSecs() + RESET_TTL_SECS;
891
941
  AuthDb.resetTokens.create(tid, rec);
892
942
  // If a concurrent reset request won the pointer index, roll back the
893
943
  // token we just minted so exactly one stays outstanding (no orphan).
894
- if (!AuthDb.resetTokenOf.create(new Username(h, owner.username), tid)) {
944
+ if (!AuthDb.resetTokenOf.create(this.secondaryKey(h, resetAccount), tid)) {
895
945
  AuthDb.resetTokens.delete(tid);
896
946
  }
897
947
  // Token in the URL FRAGMENT, not the query string: a fragment is never
@@ -955,8 +1005,11 @@ class Auth {
955
1005
  const rec = AuthDb.resetTokens.getDelete(tokenId(h, raw));
956
1006
  if (rec == null) return fail();
957
1007
  if (nowSecs() >= rec.exp) return fail();
958
- const acct = AuthDb.accounts.get(new Username(h, rec.username));
959
- if (acct == null) return fail();
1008
+ const key = new Username(h, rec.username);
1009
+ const observed = AuthDb.accounts.getCurrentSnapshot(key);
1010
+ if (observed == null) return fail();
1011
+ const acct = observed.value;
1012
+ if (acct.deleting || acct.registering || this.wasDeleted(h, acct) || !ctEqual(rec.accountId, this.incarnation(acct))) return fail();
960
1013
 
961
1014
  const regMsg = AuthService.buildRegisterMessage(rec.username, pk);
962
1015
  if (!AuthService.verifyRegister(pk, regMsg, proof)) return fail();
@@ -964,24 +1017,169 @@ class Auth {
964
1017
  // Overwrite ONLY the login verifier. Salt/params are deterministic
965
1018
  // constants (unchanged); a successful reset also implies email ownership,
966
1019
  // so confirm the account while we are here.
967
- const stableId = this.stableUserId(ctx, h, rec.username, acct);
1020
+ const stableId = this.stableUserId(ctx, h, rec.username, acct, false);
1021
+ acct.toilUserId = stableId;
1022
+ if (acct.accountId.length == 0) acct.legacyFactors = true;
1023
+ acct.accountId = this.incarnation(acct);
968
1024
  acct.publicKey = pk;
969
1025
  acct.emailConfirmed = true;
970
- AuthDb.accounts.patch(new Username(h, rec.username), acct);
1026
+ if (!AuthDb.accounts.compareExchangeSnapshot(key, observed, acct)) return fail();
971
1027
  AuthService.revokeUserSessions(__toilEncodeAuthUser(stableId, rec.username));
972
1028
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
973
1029
  }
974
1030
 
975
1031
  /** The account's STABLE ToilUserId, persisted at registration (so reset never
976
1032
  * changes it). An empty stored id is derived once, persisted, and indexed. */
977
- private stableUserId(ctx: RouteContext, h: string, username: string, acct: AuthAccount): Uint8Array {
1033
+ private stableUserId(ctx: RouteContext, h: string, username: string, acct: AuthAccount, persist: bool = true): Uint8Array {
978
1034
  if (acct.toilUserId.length > 0) return acct.toilUserId;
979
1035
  const uid = ToilUserId.derive(acct.publicKey, username, authDomain(ctx)).toBytes();
1036
+ if (!persist || acct.deleting) return uid;
1037
+ const key = new Username(h, username);
1038
+ const observed = AuthDb.accounts.getCurrentSnapshot(key);
1039
+ if (observed == null || observed.value.deleting || observed.value.registering ||
1040
+ !ctEqual(this.incarnation(observed.value), this.incarnation(acct)) || this.wasDeleted(h, observed.value))
1041
+ throw new Error('Account identity temporarily unavailable');
1042
+ if (observed.value.toilUserId.length != 0) {
1043
+ acct.toilUserId = observed.value.toilUserId;
1044
+ return acct.toilUserId;
1045
+ }
1046
+ observed.value.toilUserId = uid;
1047
+ if (!AuthDb.accounts.compareExchangeSnapshot(key, observed, observed.value))
1048
+ throw new Error('Account identity changed; retry sign-in');
980
1049
  acct.toilUserId = uid;
981
- AuthDb.accounts.patch(new Username(h, username), acct);
982
1050
  return uid;
983
1051
  }
984
1052
 
1053
+ private reserveEmail(key: EmailKey, acct: AuthAccount): bool {
1054
+ const desired = new EmailOwner(acct.username, acct.accountId);
1055
+ if (AuthDb.emails.compareExchange(key, null, desired)) return true;
1056
+ const reserved = AuthDb.emails.getCurrentSnapshot(key);
1057
+ if (reserved == null) {
1058
+ if (Db.lastError() != DbError.None) throw new Error('Email index temporarily unavailable');
1059
+ return AuthDb.emails.compareExchange(key, null, desired);
1060
+ }
1061
+ if (reserved.value.username == acct.username && ctEqual(reserved.value.accountId, acct.accountId)) return true;
1062
+ // A deleted owner's index may arrive late from a replica, or survive an
1063
+ // interrupted registration. Reclaim only if its authoritative account
1064
+ // no longer owns this email. Preserve reservations for pending accounts.
1065
+ const prior = AuthDb.accounts.getCurrentSnapshot(new Username(key.host, reserved.value.username));
1066
+ if (prior == null && Db.lastError() != DbError.None) throw new Error('Email owner temporarily unavailable');
1067
+ if (prior != null && !prior.value.deleting && prior.value.email == key.email && !this.wasDeleted(key.host, prior.value)) {
1068
+ const ownerMatches = reserved.value.accountId.length == 0
1069
+ ? prior.value.accountId.length == 0 || prior.value.legacyFactors
1070
+ : ctEqual(reserved.value.accountId, this.incarnation(prior.value));
1071
+ if (ownerMatches) return false;
1072
+ }
1073
+ if (!AuthDb.emails.compareDeleteSnapshot(key, reserved)) return false;
1074
+ return AuthDb.emails.compareExchange(key, null, desired);
1075
+ }
1076
+
1077
+ private account(key: Username): AuthAccount | null {
1078
+ const observed = AuthDb.accounts.getCurrentSnapshot(key);
1079
+ if (observed == null) {
1080
+ if (Db.lastError() != DbError.None) throw new Error('Account temporarily unavailable');
1081
+ return null;
1082
+ }
1083
+ if (observed.value.deleting || observed.value.registering || this.wasDeleted(key.host, observed.value)) return null;
1084
+ return observed.value;
1085
+ }
1086
+
1087
+ private wasDeleted(host: string, acct: AuthAccount): bool {
1088
+ const marker = AuthDb.deletedAccounts.getCurrentSnapshot(new DeletedAccountKey(host, crypto.toHex(this.incarnation(acct))));
1089
+ if (marker == null && Db.lastError() != DbError.None) throw new Error('Account deletion state temporarily unavailable');
1090
+ if (marker != null) return true;
1091
+ if (acct.accountId.length == 0 || acct.legacyFactors) {
1092
+ // Older credentials predate an incarnation field. Their salt remains
1093
+ // stable across resets, unlike the public key used in a legacy fallback.
1094
+ const legacy = AuthDb.deletedAccounts.getCurrentSnapshot(new DeletedAccountKey(host, 'legacy:' + crypto.toHex(crypto.sha256(acct.salt))));
1095
+ if (legacy == null && Db.lastError() != DbError.None) throw new Error('Account deletion state temporarily unavailable');
1096
+ return legacy != null;
1097
+ }
1098
+ return false;
1099
+ }
1100
+
1101
+ private incarnation(acct: AuthAccount): Uint8Array {
1102
+ if (acct.accountId.length != 0) return acct.accountId;
1103
+ // Legacy accounts have no incarnation. Preserve this identity across reset.
1104
+ return crypto.sha256Text(crypto.toHex(acct.salt) + ':' + crypto.toHex(acct.publicKey));
1105
+ }
1106
+
1107
+ private factorName(acct: AuthAccount): string {
1108
+ return acct.accountId.length == 0 || acct.legacyFactors ? acct.username : acct.username + '\x00' + crypto.toHex(acct.accountId);
1109
+ }
1110
+
1111
+ private secondaryKey(host: string, acct: AuthAccount): Username {
1112
+ return new Username(host, acct.username + '\x00' + crypto.toHex(this.incarnation(acct)));
1113
+ }
1114
+
1115
+ private finishDeletion(ctx: RouteContext, key: Username, observed: DocumentSnapshot<AuthAccount>): bool {
1116
+ const acct = observed.value;
1117
+ if (!acct.deleting) return false;
1118
+ const deletedKey = new DeletedAccountKey(key.host, crypto.toHex(this.incarnation(acct)));
1119
+ if (!AuthDb.deletedAccounts.compareExchange(deletedKey, null, new DeletedAccountMarker()) && !this.wasDeleted(key.host, acct))
1120
+ throw new Error('Account deletion state temporarily unavailable');
1121
+ if (acct.accountId.length == 0 || acct.legacyFactors) {
1122
+ const legacyKey = new DeletedAccountKey(key.host, 'legacy:' + crypto.toHex(crypto.sha256(acct.salt)));
1123
+ if (!AuthDb.deletedAccounts.compareExchange(legacyKey, null, new DeletedAccountMarker())) {
1124
+ const legacy = AuthDb.deletedAccounts.getCurrentSnapshot(legacyKey);
1125
+ if (legacy == null) throw new Error('Legacy deletion state temporarily unavailable');
1126
+ }
1127
+ }
1128
+ const emailKey = new EmailKey(key.host, acct.email);
1129
+ const email = AuthDb.emails.getCurrentSnapshot(emailKey);
1130
+ if (email == null && Db.lastError() != DbError.None) throw new Error('Email index temporarily unavailable');
1131
+ if (email != null && email.value.username == acct.username &&
1132
+ (email.value.accountId.length == 0 || ctEqual(email.value.accountId, this.incarnation(acct)))) {
1133
+ AuthDb.emails.compareDeleteSnapshot(emailKey, email);
1134
+ }
1135
+ const secondary = this.secondaryKey(key.host, acct);
1136
+ const confirm = AuthDb.confirmTokenOf.getDelete(secondary);
1137
+ if (confirm == null && Db.lastError() != DbError.None) throw new Error('Confirmation token temporarily unavailable');
1138
+ if (confirm != null) AuthDb.confirmTokens.delete(confirm);
1139
+ const reset = AuthDb.resetTokenOf.getDelete(secondary);
1140
+ if (reset == null && Db.lastError() != DbError.None) throw new Error('Reset token temporarily unavailable');
1141
+ if (reset != null) AuthDb.resetTokens.delete(reset);
1142
+ AuthDb.twoFaSetup.delete(secondary);
1143
+ // Pre-incarnation heads use the plain username. New accounts never use
1144
+ // these keys, so cleaning them cannot touch a later registration.
1145
+ const legacy = new Username(key.host, acct.username);
1146
+ const legacyConfirm = AuthDb.confirmTokenOf.getDelete(legacy);
1147
+ if (legacyConfirm == null && Db.lastError() != DbError.None) throw new Error('Confirmation token temporarily unavailable');
1148
+ if (legacyConfirm != null) AuthDb.confirmTokens.delete(legacyConfirm);
1149
+ const legacyReset = AuthDb.resetTokenOf.getDelete(legacy);
1150
+ if (legacyReset == null && Db.lastError() != DbError.None) throw new Error('Reset token temporarily unavailable');
1151
+ if (legacyReset != null) AuthDb.resetTokens.delete(legacyReset);
1152
+ AuthDb.twoFaSetup.delete(legacy);
1153
+ // Security epochs intentionally remain so copied cookies and in-flight
1154
+ // logins cannot become valid when the same identity signs up again.
1155
+ AuthService.revokeUserSessions(__toilEncodeAuthUser(this.stableUserId(ctx, key.host, acct.username, acct), acct.username));
1156
+ return AuthDb.accounts.compareDeleteSnapshot(key, observed);
1157
+ }
1158
+
1159
+ /** Delete the authenticated sign-in account, freeing username and email.
1160
+ * App-owned data should be cleaned by the application before this request. */
1161
+ @auth
1162
+ @post('/delete')
1163
+ public deleteAccount(ctx: RouteContext): Response {
1164
+ const user = AuthService.getUser();
1165
+ if (user == null) return fail();
1166
+ const h = realm(ctx);
1167
+ const key = new Username(h, user.username);
1168
+ let observed = AuthDb.accounts.getCurrentSnapshot(key);
1169
+ if (observed == null || !ctEqual(this.stableUserId(ctx, h, user.username, observed.value, false), user.toilUserId)) return fail();
1170
+ if (!observed.value.deleting) {
1171
+ const acct = observed.value;
1172
+ acct.deleting = true;
1173
+ if (!AuthDb.accounts.compareExchangeSnapshot(key, observed, acct)) return fail();
1174
+ observed = new DocumentSnapshot<AuthAccount>(acct, acct.encode());
1175
+ }
1176
+ if (!this.finishDeletion(ctx, key, observed)) return fail();
1177
+ const response = Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
1178
+ response.setCookie(AuthService.clearSession());
1179
+ response.setCookie(AuthService.clearUserCookie());
1180
+ return response;
1181
+ }
1182
+
985
1183
  /** GET /auth/me (@auth: 401 without a valid session) -> the typed user
986
1184
  * (bytes(toilUserId) str(username)). `AuthService.getUser()` is auto-typed
987
1185
  * to the built-in `@user` with no type argument. */
@@ -1073,8 +1271,8 @@ class Auth {
1073
1271
  // SUCCESS: consume-once, then mint the session EXACTLY like login/finish.
1074
1272
  const consumed = AuthDb.twoFaLogins.getDelete(key);
1075
1273
  if (consumed == null) return fail();
1076
- const acct = AuthDb.accounts.get(new Username(h, ch.username));
1077
- if (acct == null) return fail();
1274
+ const acct = this.account(new Username(h, ch.username));
1275
+ if (acct == null || !ctEqual(consumed.accountId, this.incarnation(acct))) return fail();
1078
1276
  const toilUserId = this.stableUserId(ctx, h, ch.username, acct);
1079
1277
  const userData = __toilEncodeAuthUser(toilUserId, ch.username);
1080
1278
  const w = new DataWriter();
@@ -1104,10 +1302,10 @@ class Auth {
1104
1302
  const u = AuthService.getUser();
1105
1303
  if (u == null) return fail();
1106
1304
  const h = realm(_ctx);
1107
- const acct = AuthDb.accounts.get(new Username(h, u.username));
1305
+ const acct = this.account(new Username(h, u.username));
1108
1306
  if (acct == null) return fail();
1109
1307
 
1110
- const settings = FactorRepository.read(h, u.username, acct.twoFactorMethod);
1308
+ const settings = FactorRepository.read(h, this.factorName(acct), acct.twoFactorMethod);
1111
1309
  // Disabling when 2FA is already off: nothing to prove; generic ST_OK.
1112
1310
  if (targetMethod == TWOFA_NONE && settings.method == TWOFA_NONE) {
1113
1311
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
@@ -1136,6 +1334,7 @@ class Auth {
1136
1334
 
1137
1335
  const c = new TwoFaChallenge();
1138
1336
  c.username = u.username;
1337
+ c.accountId = this.incarnation(acct);
1139
1338
  c.settingsRevision = settings.revision;
1140
1339
  c.sessionVersion = 2;
1141
1340
  c.sessionEpoch = SessionEpochs.current(AuthService.getSessionBytes()!);
@@ -1145,8 +1344,8 @@ class Auth {
1145
1344
  c.attempts = 0;
1146
1345
  c.targetMethod = targetMethod; // what to set account.twoFactorMethod to on success
1147
1346
  // One outstanding setup challenge per user: overwrite any prior.
1148
- AuthDb.twoFaSetup.delete(new Username(h, u.username));
1149
- AuthDb.twoFaSetup.create(new Username(h, u.username), c);
1347
+ AuthDb.twoFaSetup.delete(this.secondaryKey(h, acct));
1348
+ AuthDb.twoFaSetup.create(this.secondaryKey(h, acct), c);
1150
1349
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
1151
1350
  }
1152
1351
 
@@ -1179,7 +1378,10 @@ class Auth {
1179
1378
  );
1180
1379
  if (srl != null) return srl;
1181
1380
 
1182
- const key = new Username(h, u.username);
1381
+ const accountKey = new Username(h, u.username);
1382
+ const current = this.account(accountKey);
1383
+ if (current == null) return fail();
1384
+ const key = this.secondaryKey(h, current);
1183
1385
  const ch = AuthDb.twoFaSetup.get(key);
1184
1386
  if (ch == null || ch.sessionVersion != 2 || !SessionEpochs.allowed(AuthService.getSessionBytes()!, ch.sessionEpoch)) return fail();
1185
1387
  if (nowSecs() >= ch.exp || ch.attempts >= TWOFA_MAX_ATTEMPTS) {
@@ -1196,9 +1398,9 @@ class Auth {
1196
1398
  // SUCCESS: consume-once + apply the method change.
1197
1399
  const consumed = AuthDb.twoFaSetup.getDelete(key);
1198
1400
  if (consumed == null || nowSecs() >= consumed.exp || !this.twoFaVerifyCode(h, consumed.username, code, consumed)) return fail();
1199
- const acct = AuthDb.accounts.get(key);
1200
- if (acct == null) return fail();
1201
- if (!FactorRepository.save(h, u.username, consumed.targetMethod, consumed.settingsRevision)) return fail();
1401
+ const acct = this.account(accountKey);
1402
+ if (acct == null || !ctEqual(consumed.accountId, this.incarnation(acct))) return fail();
1403
+ if (!FactorRepository.save(h, this.factorName(acct), consumed.targetMethod, consumed.settingsRevision)) return fail();
1202
1404
  const userData = __toilEncodeAuthUser(this.stableUserId(_ctx, h, u.username, acct), u.username);
1203
1405
  AuthService.revokeUserSessions(userData);
1204
1406
  const response = Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
@@ -1215,9 +1417,9 @@ class Auth {
1215
1417
  const u = AuthService.getUser();
1216
1418
  if (u == null) return fail();
1217
1419
  const h = realm(_ctx);
1218
- const acct = AuthDb.accounts.get(new Username(h, u.username));
1420
+ const acct = this.account(new Username(h, u.username));
1219
1421
  if (acct == null) return fail();
1220
- return Response.bytes(new DataWriter().writeU8(FactorRepository.read(h, u.username, acct.twoFactorMethod).method).toBytes());
1422
+ return Response.bytes(new DataWriter().writeU8(FactorRepository.read(h, this.factorName(acct), acct.twoFactorMethod).method).toBytes());
1221
1423
  }
1222
1424
 
1223
1425
  /**
@@ -1296,19 +1498,21 @@ class Auth {
1296
1498
  ctx: RouteContext,
1297
1499
  username: string,
1298
1500
  email: string,
1501
+ account: AuthAccount,
1299
1502
  ): void {
1300
1503
  // Invalidate this user's previous confirm token (bound to one outstanding).
1301
- const prev = AuthDb.confirmTokenOf.getDelete(new Username(host, username));
1504
+ const prev = AuthDb.confirmTokenOf.getDelete(this.secondaryKey(host, account));
1302
1505
  if (prev != null) AuthDb.confirmTokens.delete(prev);
1303
1506
  const raw = mintToken();
1304
1507
  const tid = tokenId(host, raw); // realm-bound token id (see tokenId)
1305
1508
  const rec = new TokenRec();
1306
1509
  rec.username = username;
1510
+ rec.accountId = this.incarnation(account);
1307
1511
  rec.exp = nowSecs() + CONFIRM_TTL_SECS;
1308
1512
  AuthDb.confirmTokens.create(tid, rec);
1309
1513
  // If a concurrent request won the pointer index, roll back the token we
1310
1514
  // just minted so exactly one stays outstanding (no orphan).
1311
- if (!AuthDb.confirmTokenOf.create(new Username(host, username), tid)) {
1515
+ if (!AuthDb.confirmTokenOf.create(this.secondaryKey(host, account), tid)) {
1312
1516
  AuthDb.confirmTokens.delete(tid);
1313
1517
  }
1314
1518
  // Token in the URL FRAGMENT (see resetRequest): kept out of server/CDN
@@ -59,13 +59,17 @@ export class Response {
59
59
  }
60
60
 
61
61
  /**
62
- * A raw binary body, tagged `application/octet-stream`. Used by `@route`
62
+ * A raw binary body, defaulting to `application/octet-stream`. Used by `@route`
63
63
  * methods with `stream: DataStream.Binary` to ship a `@data` `encode()`.
64
64
  */
65
- public static bytes(body: Uint8Array, status: u16 = 200): Response {
65
+ public static bytes(
66
+ body: Uint8Array,
67
+ status: u16 = 200,
68
+ contentType: string = 'application/octet-stream',
69
+ ): Response {
66
70
  const r = new Response(status, body);
67
71
 
68
- r.setHeader('content-type', 'application/octet-stream');
72
+ r.setHeader('content-type', contentType);
69
73
 
70
74
  return r;
71
75
  }
@@ -100,9 +104,13 @@ export class Response {
100
104
  }
101
105
 
102
106
  /**
103
- * Builder-style: returns `this` so calls can chain.
107
+ * Replace all headers with this name (case-insensitive). Returns `this`.
104
108
  */
105
109
  public setHeader(name: string, value: string): Response {
110
+ const lower = name.toLowerCase();
111
+ for (let i = this.headers.length - 1; i >= 0; i--) {
112
+ if (this.headers[i].name.toLowerCase() === lower) this.headers.splice(i, 1);
113
+ }
106
114
  this.headers.push(new Header(name, value));
107
115
 
108
116
  return this;