@ursalock/client 0.3.0 → 0.4.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/index.d.ts CHANGED
@@ -554,4 +554,172 @@ declare function useCredential(client: VaultClient): ZKCredential | null;
554
554
  */
555
555
  declare function usePasskeySupport(client: VaultClient): boolean;
556
556
 
557
- export { type AuthProvider, type AuthResult, type AuthState, EmailAuth, type EmailAuthOptions, type EmailCredentials, type EmailSignInOptions, type EmailSignUpOptions, FetchHttpClient, type IAuthProvider, type IHttpClient, PasskeyAuth, type PasskeyAuthOptions, type PasskeySignUpOptions, type Token, TokenManager, type TokenManagerOptions, type User, VaultClient, type VaultClientOptions, type ZKAuthResult, useAuth, useCredential, usePasskeySupport, useSignIn, useSignOut, useSignUp, useUser };
557
+ /**
558
+ * Document types for UrsaLock v2
559
+ * E2E encrypted document storage
560
+ */
561
+ /** A decrypted document with typed content */
562
+ interface Document<T> {
563
+ uid: string;
564
+ collection: string;
565
+ content: T;
566
+ version: number;
567
+ createdAt: number;
568
+ updatedAt: number;
569
+ deletedAt?: number;
570
+ }
571
+ /** Server response shape (encrypted) */
572
+ interface DocumentResponse {
573
+ uid: string;
574
+ collection: string;
575
+ data: string;
576
+ hmac: string | null;
577
+ version: number;
578
+ createdAt: number;
579
+ updatedAt: number;
580
+ deletedAt: number | null;
581
+ }
582
+ /** Options for listing documents */
583
+ interface ListOptions {
584
+ collection?: string;
585
+ since?: number;
586
+ includeDeleted?: boolean;
587
+ limit?: number;
588
+ offset?: number;
589
+ }
590
+ /** Sync result */
591
+ interface SyncResult<T> {
592
+ documents: Document<T>[];
593
+ syncedAt: number;
594
+ }
595
+
596
+ /**
597
+ * Collection - Typed E2E encrypted document storage
598
+ *
599
+ * Encrypts documents client-side using AES-GCM and optionally HMAC-SHA256
600
+ * for integrity verification in transit/storage.
601
+ */
602
+
603
+ /**
604
+ * Collection provides typed, encrypted document storage
605
+ *
606
+ * All documents are encrypted client-side before being sent to the server.
607
+ * The server never sees plaintext content, only encrypted blobs.
608
+ */
609
+ declare class Collection<T> {
610
+ private serverUrl;
611
+ private vaultUid;
612
+ private collectionName;
613
+ private encryptionKey;
614
+ private hmacKey;
615
+ private getAuthHeader;
616
+ private httpClient?;
617
+ constructor(serverUrl: string, vaultUid: string, collectionName: string, encryptionKey: Uint8Array, hmacKey: Uint8Array | undefined, getAuthHeader: () => Record<string, string>, httpClient?: IHttpClient | undefined);
618
+ /**
619
+ * Create a new document
620
+ * @param content Document content (will be encrypted)
621
+ * @returns Created document with server-generated uid
622
+ */
623
+ create(content: T): Promise<Document<T>>;
624
+ /**
625
+ * Get a document by uid
626
+ * @param uid Document uid
627
+ * @returns Decrypted document
628
+ */
629
+ get(uid: string): Promise<Document<T>>;
630
+ /**
631
+ * List documents in this collection
632
+ * @param options Filter options
633
+ * @returns Array of decrypted documents
634
+ */
635
+ list(options?: Omit<ListOptions, 'collection'>): Promise<Document<T>[]>;
636
+ /**
637
+ * Update a document
638
+ * @param uid Document uid
639
+ * @param content Partial content to merge (or full replacement)
640
+ * @returns Updated document
641
+ */
642
+ update(uid: string, content: Partial<T>): Promise<Document<T>>;
643
+ /**
644
+ * Delete a document (soft delete)
645
+ * @param uid Document uid
646
+ */
647
+ delete(uid: string): Promise<void>;
648
+ /**
649
+ * Sync documents since a timestamp
650
+ * @param since Timestamp (ms) to sync from
651
+ * @returns Sync result with documents and sync timestamp
652
+ */
653
+ sync(since?: number): Promise<SyncResult<T>>;
654
+ /**
655
+ * Encrypt content to base64 string with optional HMAC
656
+ */
657
+ private encryptContent;
658
+ /**
659
+ * Decrypt a server response document
660
+ */
661
+ private decryptDocument;
662
+ /**
663
+ * Make authenticated HTTP request
664
+ */
665
+ private request;
666
+ /**
667
+ * Convert Uint8Array to base64 string
668
+ */
669
+ private bytesToBase64;
670
+ /**
671
+ * Convert base64 string to Uint8Array
672
+ */
673
+ private base64ToBytes;
674
+ }
675
+
676
+ /**
677
+ * DocumentClient - Factory for creating typed Collections
678
+ *
679
+ * Provides a simple interface to create Collection instances
680
+ * with shared configuration (server URL, vault UID, encryption keys).
681
+ */
682
+
683
+ interface DocumentClientOptions {
684
+ /** Server URL */
685
+ serverUrl: string;
686
+ /** Vault UID */
687
+ vaultUid: string;
688
+ /** 256-bit encryption key */
689
+ encryptionKey: Uint8Array;
690
+ /** Optional HMAC key for integrity verification */
691
+ hmacKey?: Uint8Array;
692
+ /** Function to get current auth header */
693
+ getAuthHeader: () => Record<string, string>;
694
+ /** Optional HTTP client (defaults to fetch) */
695
+ httpClient?: IHttpClient;
696
+ }
697
+ /**
698
+ * DocumentClient creates typed Collections with shared configuration
699
+ *
700
+ * @example
701
+ * ```ts
702
+ * const client = new DocumentClient({
703
+ * serverUrl: 'https://api.ursalock.com',
704
+ * vaultUid: 'vault-123',
705
+ * encryptionKey: key,
706
+ * hmacKey: hmacKey,
707
+ * getAuthHeader: () => ({ Authorization: `Bearer ${token}` }),
708
+ * });
709
+ *
710
+ * const notes = client.collection<Note>('notes');
711
+ * await notes.create({ title: 'Secret note', content: 'Hello' });
712
+ * ```
713
+ */
714
+ declare class DocumentClient {
715
+ private options;
716
+ constructor(options: DocumentClientOptions);
717
+ /**
718
+ * Get a typed collection
719
+ * @param name Collection name
720
+ * @returns Collection instance
721
+ */
722
+ collection<T>(name: string): Collection<T>;
723
+ }
724
+
725
+ export { type AuthProvider, type AuthResult, type AuthState, Collection, type Document, DocumentClient, type DocumentClientOptions, type DocumentResponse, EmailAuth, type EmailAuthOptions, type EmailCredentials, type EmailSignInOptions, type EmailSignUpOptions, FetchHttpClient, type IAuthProvider, type IHttpClient, type ListOptions, PasskeyAuth, type PasskeyAuthOptions, type PasskeySignUpOptions, type SyncResult, type Token, TokenManager, type TokenManagerOptions, type User, VaultClient, type VaultClientOptions, type ZKAuthResult, useAuth, useCredential, usePasskeySupport, useSignIn, useSignOut, useSignUp, useUser };
package/dist/index.js CHANGED
@@ -802,4 +802,375 @@ function usePasskeySupport(client) {
802
802
  return client.supportsPasskey();
803
803
  }
804
804
 
805
- export { EmailAuth, FetchHttpClient, PasskeyAuth, TokenManager, VaultClient, useAuth, useCredential, usePasskeySupport, useSignIn, useSignOut, useSignUp, useUser };
805
+ // ../crypto/dist/index.js
806
+ function randomBytes(length) {
807
+ const bytes = new Uint8Array(length);
808
+ crypto.getRandomValues(bytes);
809
+ return bytes;
810
+ }
811
+ function concatBytes(...arrays) {
812
+ const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
813
+ const result = new Uint8Array(totalLength);
814
+ let offset = 0;
815
+ for (const arr of arrays) {
816
+ result.set(arr, offset);
817
+ offset += arr.length;
818
+ }
819
+ return result;
820
+ }
821
+ var IV_LENGTH = 12;
822
+ var WebCryptoProvider = class {
823
+ async encrypt(plaintext, key) {
824
+ if (key.length !== 32) {
825
+ throw new Error(`Invalid key length: expected 32 bytes, got ${key.length}`);
826
+ }
827
+ const actualIv = randomBytes(IV_LENGTH);
828
+ const cryptoKey = await crypto.subtle.importKey(
829
+ "raw",
830
+ key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength),
831
+ { name: "AES-GCM" },
832
+ false,
833
+ ["encrypt"]
834
+ );
835
+ const ciphertext = new Uint8Array(
836
+ await crypto.subtle.encrypt(
837
+ { name: "AES-GCM", iv: actualIv },
838
+ cryptoKey,
839
+ plaintext.buffer.slice(plaintext.byteOffset, plaintext.byteOffset + plaintext.byteLength)
840
+ )
841
+ );
842
+ const combined = concatBytes(actualIv, ciphertext);
843
+ return { iv: actualIv, ciphertext, combined };
844
+ }
845
+ async decrypt(encrypted, key) {
846
+ if (key.length !== 32) {
847
+ throw new Error(`Invalid key length: expected 32 bytes, got ${key.length}`);
848
+ }
849
+ let iv;
850
+ let ciphertext;
851
+ if (encrypted instanceof Uint8Array) {
852
+ if (encrypted.length < IV_LENGTH + 16) {
853
+ throw new Error("Invalid encrypted data: too short");
854
+ }
855
+ iv = encrypted.slice(0, IV_LENGTH);
856
+ ciphertext = encrypted.slice(IV_LENGTH);
857
+ } else {
858
+ iv = encrypted.iv;
859
+ ciphertext = encrypted.ciphertext;
860
+ }
861
+ const cryptoKey = await crypto.subtle.importKey(
862
+ "raw",
863
+ key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength),
864
+ { name: "AES-GCM" },
865
+ false,
866
+ ["decrypt"]
867
+ );
868
+ try {
869
+ const plaintext = await crypto.subtle.decrypt(
870
+ { name: "AES-GCM", iv },
871
+ cryptoKey,
872
+ ciphertext.buffer.slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength)
873
+ );
874
+ return new Uint8Array(plaintext);
875
+ } catch (error) {
876
+ throw new Error("Decryption failed: invalid key or corrupted data");
877
+ }
878
+ }
879
+ };
880
+ var defaultProvider = new WebCryptoProvider();
881
+ async function encrypt(plaintext, key, provider = defaultProvider) {
882
+ return provider.encrypt(plaintext, key);
883
+ }
884
+ async function decrypt(encrypted, key, provider = defaultProvider) {
885
+ return provider.decrypt(encrypted, key);
886
+ }
887
+ async function computeHmac(data, key) {
888
+ const cryptoKey = await crypto.subtle.importKey(
889
+ "raw",
890
+ key,
891
+ { name: "HMAC", hash: "SHA-256" },
892
+ false,
893
+ ["sign"]
894
+ );
895
+ const signature = new Uint8Array(
896
+ await crypto.subtle.sign("HMAC", cryptoKey, data)
897
+ );
898
+ return bytesToHex(signature);
899
+ }
900
+ async function verifyHmac(data, key, expectedHmac) {
901
+ const cryptoKey = await crypto.subtle.importKey(
902
+ "raw",
903
+ key,
904
+ { name: "HMAC", hash: "SHA-256" },
905
+ false,
906
+ ["verify"]
907
+ );
908
+ const expectedBytes = hexToBytes(expectedHmac);
909
+ return crypto.subtle.verify(
910
+ "HMAC",
911
+ cryptoKey,
912
+ expectedBytes,
913
+ data
914
+ );
915
+ }
916
+ function bytesToHex(bytes) {
917
+ let hex = "";
918
+ for (let i = 0; i < bytes.length; i++) {
919
+ hex += bytes[i].toString(16).padStart(2, "0");
920
+ }
921
+ return hex;
922
+ }
923
+ function hexToBytes(hex) {
924
+ const len = hex.length >>> 1;
925
+ const bytes = new Uint8Array(len);
926
+ for (let i = 0; i < len; i++) {
927
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
928
+ }
929
+ return bytes;
930
+ }
931
+
932
+ // src/collection.ts
933
+ var Collection = class {
934
+ constructor(serverUrl, vaultUid, collectionName, encryptionKey, hmacKey, getAuthHeader, httpClient) {
935
+ this.serverUrl = serverUrl;
936
+ this.vaultUid = vaultUid;
937
+ this.collectionName = collectionName;
938
+ this.encryptionKey = encryptionKey;
939
+ this.hmacKey = hmacKey;
940
+ this.getAuthHeader = getAuthHeader;
941
+ this.httpClient = httpClient;
942
+ }
943
+ /**
944
+ * Create a new document
945
+ * @param content Document content (will be encrypted)
946
+ * @returns Created document with server-generated uid
947
+ */
948
+ async create(content) {
949
+ const { data, hmac } = await this.encryptContent(content);
950
+ const body = {
951
+ collection: this.collectionName,
952
+ data
953
+ };
954
+ if (hmac) {
955
+ body.hmac = hmac;
956
+ }
957
+ const response = await this.request(`/vault/${this.vaultUid}/documents`, {
958
+ method: "POST",
959
+ headers: {
960
+ "Content-Type": "application/json"
961
+ },
962
+ body: JSON.stringify(body)
963
+ });
964
+ return this.decryptDocument(response);
965
+ }
966
+ /**
967
+ * Get a document by uid
968
+ * @param uid Document uid
969
+ * @returns Decrypted document
970
+ */
971
+ async get(uid) {
972
+ const response = await this.request(`/vault/${this.vaultUid}/documents/${uid}`);
973
+ return this.decryptDocument(response);
974
+ }
975
+ /**
976
+ * List documents in this collection
977
+ * @param options Filter options
978
+ * @returns Array of decrypted documents
979
+ */
980
+ async list(options) {
981
+ const params = new URLSearchParams();
982
+ params.set("collection", this.collectionName);
983
+ if (options?.since !== void 0) {
984
+ params.set("since", String(options.since));
985
+ }
986
+ if (options?.includeDeleted !== void 0) {
987
+ params.set("includeDeleted", String(options.includeDeleted));
988
+ }
989
+ if (options?.limit !== void 0) {
990
+ params.set("limit", String(options.limit));
991
+ }
992
+ if (options?.offset !== void 0) {
993
+ params.set("offset", String(options.offset));
994
+ }
995
+ const response = await this.request(
996
+ `/vault/${this.vaultUid}/documents?${params}`
997
+ );
998
+ return Promise.all(response.documents.map((doc) => this.decryptDocument(doc)));
999
+ }
1000
+ /**
1001
+ * Update a document
1002
+ * @param uid Document uid
1003
+ * @param content Partial content to merge (or full replacement)
1004
+ * @returns Updated document
1005
+ */
1006
+ async update(uid, content) {
1007
+ const current = await this.get(uid);
1008
+ const merged = { ...current.content, ...content };
1009
+ const { data, hmac } = await this.encryptContent(merged);
1010
+ const body = {
1011
+ data,
1012
+ version: current.version
1013
+ };
1014
+ if (hmac) {
1015
+ body.hmac = hmac;
1016
+ }
1017
+ const response = await this.request(`/vault/${this.vaultUid}/documents/${uid}`, {
1018
+ method: "PUT",
1019
+ headers: {
1020
+ "Content-Type": "application/json"
1021
+ },
1022
+ body: JSON.stringify(body)
1023
+ });
1024
+ return this.decryptDocument(response);
1025
+ }
1026
+ /**
1027
+ * Delete a document (soft delete)
1028
+ * @param uid Document uid
1029
+ */
1030
+ async delete(uid) {
1031
+ await this.request(`/vault/${this.vaultUid}/documents/${uid}`, {
1032
+ method: "DELETE"
1033
+ });
1034
+ }
1035
+ /**
1036
+ * Sync documents since a timestamp
1037
+ * @param since Timestamp (ms) to sync from
1038
+ * @returns Sync result with documents and sync timestamp
1039
+ */
1040
+ async sync(since) {
1041
+ const params = new URLSearchParams();
1042
+ if (since !== void 0) {
1043
+ params.set("since", String(since));
1044
+ }
1045
+ const response = await this.request(
1046
+ `/vault/${this.vaultUid}/documents/sync?${params}`
1047
+ );
1048
+ const documents = await Promise.all(
1049
+ response.documents.map((doc) => this.decryptDocument(doc))
1050
+ );
1051
+ return {
1052
+ documents,
1053
+ syncedAt: response.syncedAt
1054
+ };
1055
+ }
1056
+ // ==================
1057
+ // Private Helpers
1058
+ // ==================
1059
+ /**
1060
+ * Encrypt content to base64 string with optional HMAC
1061
+ */
1062
+ async encryptContent(content) {
1063
+ const json = JSON.stringify(content);
1064
+ const plaintext = new TextEncoder().encode(json);
1065
+ const encrypted = await encrypt(plaintext, this.encryptionKey);
1066
+ const data = this.bytesToBase64(encrypted.combined);
1067
+ let hmac;
1068
+ if (this.hmacKey) {
1069
+ const dataBytes = new TextEncoder().encode(data);
1070
+ hmac = await computeHmac(dataBytes, this.hmacKey);
1071
+ }
1072
+ return { data, hmac };
1073
+ }
1074
+ /**
1075
+ * Decrypt a server response document
1076
+ */
1077
+ async decryptDocument(response) {
1078
+ if (this.hmacKey && response.hmac) {
1079
+ const dataBytes = new TextEncoder().encode(response.data);
1080
+ const valid = await verifyHmac(dataBytes, this.hmacKey, response.hmac);
1081
+ if (!valid) {
1082
+ throw new Error("HMAC verification failed: invalid signature");
1083
+ }
1084
+ }
1085
+ const encryptedBytes = this.base64ToBytes(response.data);
1086
+ const plaintext = await decrypt(encryptedBytes, this.encryptionKey);
1087
+ const json = new TextDecoder().decode(plaintext);
1088
+ const content = JSON.parse(json);
1089
+ return {
1090
+ uid: response.uid,
1091
+ collection: response.collection,
1092
+ content,
1093
+ version: response.version,
1094
+ createdAt: response.createdAt,
1095
+ updatedAt: response.updatedAt,
1096
+ deletedAt: response.deletedAt ?? void 0
1097
+ };
1098
+ }
1099
+ /**
1100
+ * Make authenticated HTTP request
1101
+ */
1102
+ async request(path, options = {}) {
1103
+ const url = path.startsWith("http") ? path : `${this.serverUrl}${path}`;
1104
+ const headers = {
1105
+ ...this.getAuthHeader(),
1106
+ ...options.headers
1107
+ };
1108
+ const fetchFn = this.httpClient ? (url2, opts) => this.httpClient.fetch(url2, opts) : fetch;
1109
+ const response = await fetchFn(url, { ...options, headers });
1110
+ if (!response.ok) {
1111
+ let errorMessage;
1112
+ try {
1113
+ const errorData = await response.json();
1114
+ errorMessage = errorData.message || errorData.error || response.statusText;
1115
+ } catch {
1116
+ errorMessage = response.statusText;
1117
+ }
1118
+ if (response.status === 404) {
1119
+ throw new Error(`Document not found: ${errorMessage}`);
1120
+ } else if (response.status === 409) {
1121
+ throw new Error(`Conflict: ${errorMessage}`);
1122
+ } else if (response.status === 401) {
1123
+ throw new Error(`Unauthorized: ${errorMessage}`);
1124
+ } else {
1125
+ throw new Error(`HTTP ${response.status}: ${errorMessage}`);
1126
+ }
1127
+ }
1128
+ return response.json();
1129
+ }
1130
+ /**
1131
+ * Convert Uint8Array to base64 string
1132
+ */
1133
+ bytesToBase64(bytes) {
1134
+ let binary = "";
1135
+ for (let i = 0; i < bytes.length; i++) {
1136
+ binary += String.fromCharCode(bytes[i]);
1137
+ }
1138
+ return btoa(binary);
1139
+ }
1140
+ /**
1141
+ * Convert base64 string to Uint8Array
1142
+ */
1143
+ base64ToBytes(base64) {
1144
+ const binary = atob(base64);
1145
+ const bytes = new Uint8Array(binary.length);
1146
+ for (let i = 0; i < binary.length; i++) {
1147
+ bytes[i] = binary.charCodeAt(i);
1148
+ }
1149
+ return bytes;
1150
+ }
1151
+ };
1152
+
1153
+ // src/document-client.ts
1154
+ var DocumentClient = class {
1155
+ constructor(options) {
1156
+ this.options = options;
1157
+ }
1158
+ /**
1159
+ * Get a typed collection
1160
+ * @param name Collection name
1161
+ * @returns Collection instance
1162
+ */
1163
+ collection(name) {
1164
+ return new Collection(
1165
+ this.options.serverUrl,
1166
+ this.options.vaultUid,
1167
+ name,
1168
+ this.options.encryptionKey,
1169
+ this.options.hmacKey,
1170
+ this.options.getAuthHeader,
1171
+ this.options.httpClient
1172
+ );
1173
+ }
1174
+ };
1175
+
1176
+ export { Collection, DocumentClient, EmailAuth, FetchHttpClient, PasskeyAuth, TokenManager, VaultClient, useAuth, useCredential, usePasskeySupport, useSignIn, useSignOut, useSignUp, useUser };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ursalock/client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Auth and API client for ursalock with E2EE passkey encryption",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",