toiljs 0.0.107 → 0.0.109
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/CHANGELOG.md +5 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/db/catalog.js +7 -3
- package/build/devserver/db/database.d.ts +2 -0
- package/build/devserver/db/database.js +69 -2
- package/build/devserver/db/types.d.ts +7 -0
- package/package.json +3 -3
- package/server/auth/AuthController.ts +11 -41
- package/src/devserver/db/catalog.ts +8 -3
- package/src/devserver/db/database.ts +74 -4
- package/src/devserver/db/types.ts +10 -0
|
@@ -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
|
|
654
|
-
//
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
|
662
|
-
//
|
|
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();
|
|
77
|
-
r.readString();
|
|
78
|
-
r.readU8()
|
|
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,71 @@ 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
|
+
r.readU32(); // skip the @data message-boundary id that encode() writes before the fields
|
|
697
|
+
const out: { index: number; val: Buffer }[] = [];
|
|
698
|
+
for (let i = 0; i < fields.length; i++) {
|
|
699
|
+
const f = fields[i];
|
|
700
|
+
if (f.isArray) break;
|
|
701
|
+
if (f.typeName === 'string') {
|
|
702
|
+
const s = r.readString();
|
|
703
|
+
if (f.unique) out.push({ index: i, val: Buffer.from(s, 'utf8') });
|
|
704
|
+
} else if (f.typeName === 'Uint8Array') {
|
|
705
|
+
const b = r.readBytes();
|
|
706
|
+
if (f.unique) out.push({ index: i, val: Buffer.from(b) });
|
|
707
|
+
} else {
|
|
708
|
+
switch (f.typeName) {
|
|
709
|
+
case 'bool':
|
|
710
|
+
case 'u8':
|
|
711
|
+
case 'i8':
|
|
712
|
+
r.readU8();
|
|
713
|
+
break;
|
|
714
|
+
case 'u16':
|
|
715
|
+
case 'i16':
|
|
716
|
+
r.readU16();
|
|
717
|
+
break;
|
|
718
|
+
case 'u32':
|
|
719
|
+
case 'i32':
|
|
720
|
+
case 'f32':
|
|
721
|
+
r.readU32();
|
|
722
|
+
break;
|
|
723
|
+
case 'u64':
|
|
724
|
+
case 'i64':
|
|
725
|
+
case 'f64':
|
|
726
|
+
r.readU64();
|
|
727
|
+
break;
|
|
728
|
+
default:
|
|
729
|
+
return out; // nested @data — compiler forbids @unique on/after it
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return out;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** True when another record in `coll` already holds one of `value`'s `@unique`
|
|
737
|
+
* field values (per-tenant uniqueness). `selfSk` is the writing record's store
|
|
738
|
+
* key so it never conflicts with itself; empty values are unique-if-present. */
|
|
739
|
+
private uniqueConflict(coll: DevCollectionHandle, value: Buffer, selfSk: string): boolean {
|
|
740
|
+
const uvals = this.uniqueValuesOf(coll, value).filter((u) => u.val.length > 0);
|
|
741
|
+
if (uvals.length === 0) return false;
|
|
742
|
+
const prefix = coll.name + '\0';
|
|
743
|
+
for (const [otherSk, otherVal] of this.store) {
|
|
744
|
+
if (otherSk === selfSk || !otherSk.startsWith(prefix)) continue;
|
|
745
|
+
const otherUvals = this.uniqueValuesOf(coll, otherVal);
|
|
746
|
+
for (const u of uvals) {
|
|
747
|
+
if (otherUvals.some((o) => o.index === u.index && o.val.equals(u.val))) return true;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
|
|
688
753
|
create(
|
|
689
754
|
ref: MemoryRef,
|
|
690
755
|
db: DbDevState,
|
|
@@ -706,9 +771,12 @@ export class DevDatabase {
|
|
|
706
771
|
if (!start.fresh)
|
|
707
772
|
return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
|
|
708
773
|
const sk = storeKey(coll.name, key);
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
774
|
+
// @unique: a value already held by another record blocks the create, exactly
|
|
775
|
+
// like the edge's op_create claim (surfaced as AlreadyExists -> create false).
|
|
776
|
+
const outcome: RecordOutcome =
|
|
777
|
+
this.store.has(sk) || this.uniqueConflict(coll, value, sk)
|
|
778
|
+
? { kind: 'already_exists' }
|
|
779
|
+
: { kind: 'unit' };
|
|
712
780
|
if (outcome.kind === 'unit') {
|
|
713
781
|
this.store.set(sk, value);
|
|
714
782
|
this.stampVersion(coll, sk); // stamp the value type's current schema version
|
|
@@ -740,7 +808,9 @@ export class DevDatabase {
|
|
|
740
808
|
return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
|
|
741
809
|
const sk = storeKey(coll.name, key);
|
|
742
810
|
const outcome: RecordOutcome = this.store.has(sk)
|
|
743
|
-
?
|
|
811
|
+
? this.uniqueConflict(coll, v, sk)
|
|
812
|
+
? { kind: 'already_exists' } // patch would collide a @unique field
|
|
813
|
+
: { kind: 'value', value: v, schemaVersion: this.currentSchemaVersion(coll) }
|
|
744
814
|
: { kind: 'not_found' };
|
|
745
815
|
if (outcome.kind === 'value') {
|
|
746
816
|
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 =
|