toiljs 0.0.107 → 0.0.108

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.
@@ -443,19 +443,6 @@ class EmailOwner {
443
443
  }
444
444
  }
445
445
 
446
- /** Reverse-index key for the STABLE ToilUserId uniqueness guard: (realm host,
447
- * 32-byte id). Realm-scoped, so uniqueness is enforced PER TENANT — two sites
448
- * never share an id namespace. */
449
- @data
450
- class UserIdKey {
451
- host: string = '';
452
- id: Uint8Array = new Uint8Array(0);
453
- constructor(host: string = '', id: Uint8Array = new Uint8Array(0)) {
454
- this.host = host;
455
- this.id = id;
456
- }
457
- }
458
-
459
446
  @data
460
447
  class TokenId {
461
448
  hash: Uint8Array = new Uint8Array(0);
@@ -550,7 +537,6 @@ class TwoFaChallenge {
550
537
  class AuthDb {
551
538
  @collection static accounts: Documents<Username, AuthAccount>;
552
539
  @collection static emails: Documents<EmailKey, EmailOwner>; // email -> username (uniqueness index)
553
- @collection static userIds: Documents<UserIdKey, EmailOwner>; // toilUserId -> username (per-tenant uniqueness guard)
554
540
  @collection static challenges: Documents<ChallengeId, Challenge>;
555
541
  @collection static confirmTokens: Documents<TokenId, TokenRec>;
556
542
  @collection static resetTokens: Documents<TokenId, TokenRec>;
@@ -626,16 +612,6 @@ class Auth {
626
612
  // on domain A is a DIFFERENT account + index entry from the same on domain B.
627
613
  const h = realm(ctx);
628
614
 
629
- // Distinguishable statuses (not the generic 401) so the UI can say
630
- // "username taken" / "email in use". Signup intentionally leaks existence
631
- // (a product choice, now gated behind the PoP above); reset never does.
632
- if (AuthDb.accounts.exists(new Username(h, username))) {
633
- return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
634
- }
635
- if (AuthDb.emails.exists(new EmailKey(h, email))) {
636
- return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
637
- }
638
-
639
615
  // Send the confirm email + store unconfirmed when EITHER flag is on
640
616
  // (AUTH_EMAIL_CONFIRMATION or the stricter AUTH_REQUIRE_EMAIL_CONFIRMATION).
641
617
  // Whether login is then BLOCKED is a separate check (requireConfirmation,
@@ -650,29 +626,24 @@ class Auth {
650
626
  a.memKiB = MEM_KIB;
651
627
  a.iterations = ITERS;
652
628
  a.parallelism = PAR;
653
- // Derive + persist the stable id once, here. Tenant-scoped (framed domain +
654
- // realm-keyed reverse index below).
655
- const uid = ToilUserId.derive(pk, username, authDomain(ctx)).toBytes();
656
- a.toilUserId = uid;
657
- // create-if-absent: a racing duplicate registration loses here, not above.
629
+ // Derive + persist the stable id once. Tenant-scoped, and unique WITHOUT a
630
+ // separate index: the framed derive maps distinct (username, domain) to
631
+ // distinct ids, and username is unique per realm (accounts.create below), so
632
+ // two accounts can never share an id.
633
+ a.toilUserId = ToilUserId.derive(pk, username, authDomain(ctx)).toBytes();
634
+ // create-if-absent IS the authoritative, race-safe uniqueness guard
635
+ // (serialized at the key's home): a duplicate username loses here and gets the
636
+ // distinguishable ST_TAKEN. No pre-`exists` read needed -- the create result
637
+ // is the answer.
658
638
  if (!AuthDb.accounts.create(new Username(h, username), a)) {
659
639
  return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
660
640
  }
661
- // Reserve the email -> username index (uniqueness for reset-by-email, scoped
662
- // per host). A racing duplicate email loses here: roll back the account we
663
- // just created so a lost email race can't orphan a username whose email index
664
- // points at someone else (which would also break reset-by-email for it).
641
+ // Reserve the email -> username index (email uniqueness + reset-by-email). A
642
+ // duplicate email loses here; roll back the account so nothing is orphaned.
665
643
  if (!AuthDb.emails.create(new EmailKey(h, email), new EmailOwner(username))) {
666
644
  AuthDb.accounts.delete(new Username(h, username));
667
645
  return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
668
646
  }
669
- // Per-tenant id-uniqueness guard: atomic create of toilUserId -> username
670
- // (race-safe at the key's home). A lost race rolls back email + account.
671
- if (!AuthDb.userIds.create(new UserIdKey(h, uid), new EmailOwner(username))) {
672
- AuthDb.emails.delete(new EmailKey(h, email));
673
- AuthDb.accounts.delete(new Username(h, username));
674
- return fail();
675
- }
676
647
 
677
648
  if (mustConfirm) {
678
649
  this.issueConfirmation(h, ctx, username, email);
@@ -992,7 +963,6 @@ class Auth {
992
963
  const uid = ToilUserId.derive(acct.publicKey, username, authDomain(ctx)).toBytes();
993
964
  acct.toilUserId = uid;
994
965
  AuthDb.accounts.patch(new Username(h, username), acct);
995
- AuthDb.userIds.create(new UserIdKey(h, uid), new EmailOwner(username));
996
966
  return uid;
997
967
  }
998
968
 
@@ -21,6 +21,7 @@ import {
21
21
  type DbCatalogState,
22
22
  DEFAULT_FILL_WAIT_MS,
23
23
  type DevCollectionHandle,
24
+ type DevField,
24
25
  isCollectionFamily,
25
26
  MAX_FILL_WAIT_MS,
26
27
  } from './types.js';
@@ -72,10 +73,13 @@ export function parseCatalog(wasm: Buffer): DbCatalogState {
72
73
  return { kind: 'malformed' };
73
74
  const fillAllowStale = fillAllowStaleByte === 1;
74
75
  const nFields = r.readU16();
76
+ const fields: DevField[] = [];
75
77
  for (let f = 0; f < nFields; f++) {
76
- r.readString(); // field name
77
- r.readString(); // field type
78
- r.readU8(); // isArray
78
+ const fName = r.readString();
79
+ const fType = r.readString();
80
+ const isArray = r.readU8() !== 0;
81
+ const unique = r.readU8() !== 0;
82
+ fields.push({ name: fName, typeName: fType, isArray, unique });
79
83
  }
80
84
  const nMig = r.readU16();
81
85
  for (let m = 0; m < nMig; m++) r.readU32(); // migratableFrom versions
@@ -96,6 +100,7 @@ export function parseCatalog(wasm: Buffer): DbCatalogState {
96
100
  placement,
97
101
  fillMaxWaitMs,
98
102
  fillAllowStale,
103
+ fields,
99
104
  });
100
105
  }
101
106
  }
@@ -685,6 +685,70 @@ export class DevDatabase {
685
685
  return this.store.has(storeKey(coll.name, readKey(ref, keyPtr, keyLen))) ? 1 : 0;
686
686
  }
687
687
 
688
+ /** Each `@unique` field's raw value in an encoded record, per the collection's
689
+ * field layout. `[]` when there is no layout or no `@unique` field. The
690
+ * compiler restricts `@unique` to top-level scalar/blob records, so the walker
691
+ * only skips fixed scalars and length-prefixed blobs. */
692
+ private uniqueValuesOf(coll: DevCollectionHandle, value: Buffer): { index: number; val: Buffer }[] {
693
+ const fields = coll.fields;
694
+ if (!fields || !fields.some((f) => f.unique)) return [];
695
+ const r = new DataReader(value);
696
+ const out: { index: number; val: Buffer }[] = [];
697
+ for (let i = 0; i < fields.length; i++) {
698
+ const f = fields[i];
699
+ if (f.isArray) break;
700
+ if (f.typeName === 'string') {
701
+ const s = r.readString();
702
+ if (f.unique) out.push({ index: i, val: Buffer.from(s, 'utf8') });
703
+ } else if (f.typeName === 'Uint8Array') {
704
+ const b = r.readBytes();
705
+ if (f.unique) out.push({ index: i, val: Buffer.from(b) });
706
+ } else {
707
+ switch (f.typeName) {
708
+ case 'bool':
709
+ case 'u8':
710
+ case 'i8':
711
+ r.readU8();
712
+ break;
713
+ case 'u16':
714
+ case 'i16':
715
+ r.readU16();
716
+ break;
717
+ case 'u32':
718
+ case 'i32':
719
+ case 'f32':
720
+ r.readU32();
721
+ break;
722
+ case 'u64':
723
+ case 'i64':
724
+ case 'f64':
725
+ r.readU64();
726
+ break;
727
+ default:
728
+ return out; // nested @data — compiler forbids @unique on/after it
729
+ }
730
+ }
731
+ }
732
+ return out;
733
+ }
734
+
735
+ /** True when another record in `coll` already holds one of `value`'s `@unique`
736
+ * field values (per-tenant uniqueness). `selfSk` is the writing record's store
737
+ * key so it never conflicts with itself; empty values are unique-if-present. */
738
+ private uniqueConflict(coll: DevCollectionHandle, value: Buffer, selfSk: string): boolean {
739
+ const uvals = this.uniqueValuesOf(coll, value).filter((u) => u.val.length > 0);
740
+ if (uvals.length === 0) return false;
741
+ const prefix = coll.name + '\0';
742
+ for (const [otherSk, otherVal] of this.store) {
743
+ if (otherSk === selfSk || !otherSk.startsWith(prefix)) continue;
744
+ const otherUvals = this.uniqueValuesOf(coll, otherVal);
745
+ for (const u of uvals) {
746
+ if (otherUvals.some((o) => o.index === u.index && o.val.equals(u.val))) return true;
747
+ }
748
+ }
749
+ return false;
750
+ }
751
+
688
752
  create(
689
753
  ref: MemoryRef,
690
754
  db: DbDevState,
@@ -706,9 +770,12 @@ export class DevDatabase {
706
770
  if (!start.fresh)
707
771
  return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
708
772
  const sk = storeKey(coll.name, key);
709
- const outcome: RecordOutcome = this.store.has(sk)
710
- ? { kind: 'already_exists' }
711
- : { kind: 'unit' };
773
+ // @unique: a value already held by another record blocks the create, exactly
774
+ // like the edge's op_create claim (surfaced as AlreadyExists -> create false).
775
+ const outcome: RecordOutcome =
776
+ this.store.has(sk) || this.uniqueConflict(coll, value, sk)
777
+ ? { kind: 'already_exists' }
778
+ : { kind: 'unit' };
712
779
  if (outcome.kind === 'unit') {
713
780
  this.store.set(sk, value);
714
781
  this.stampVersion(coll, sk); // stamp the value type's current schema version
@@ -740,7 +807,9 @@ export class DevDatabase {
740
807
  return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
741
808
  const sk = storeKey(coll.name, key);
742
809
  const outcome: RecordOutcome = this.store.has(sk)
743
- ? { kind: 'value', value: v, schemaVersion: this.currentSchemaVersion(coll) }
810
+ ? this.uniqueConflict(coll, v, sk)
811
+ ? { kind: 'already_exists' } // patch would collide a @unique field
812
+ : { kind: 'value', value: v, schemaVersion: this.currentSchemaVersion(coll) }
744
813
  : { kind: 'not_found' };
745
814
  if (outcome.kind === 'value') {
746
815
  this.store.set(sk, v);
@@ -28,6 +28,13 @@ export function isCollectionFamily(value: number): value is CollectionFamily {
28
28
  return value >= CollectionFamily.Record && value <= CollectionFamily.Capacity;
29
29
  }
30
30
 
31
+ export interface DevField {
32
+ name: string;
33
+ typeName: string;
34
+ isArray: boolean;
35
+ unique: boolean;
36
+ }
37
+
31
38
  export interface DevCollectionHandle {
32
39
  name: string;
33
40
  family: CollectionFamily;
@@ -36,6 +43,9 @@ export interface DevCollectionHandle {
36
43
  placement: number;
37
44
  fillMaxWaitMs: number;
38
45
  fillAllowStale: boolean;
46
+ /** The value type's field layout (declaration order); present when the catalog
47
+ * carried one. Drives @unique enforcement. */
48
+ fields?: DevField[];
39
49
  }
40
50
 
41
51
  export type DbCatalogState =