@syncular/client 0.15.8 → 0.15.9

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.
@@ -20,14 +20,31 @@ import type { CompiledClientTable } from './schema.js';
20
20
  export interface EncryptionConfig {
21
21
  /** `keyId → 32-byte key`, or `undefined` if unknown (decrypt fails loud). */
22
22
  readonly keyProvider: (keyId: string) => Uint8Array | undefined;
23
- /** Choose the key-id for an encrypt. Default: per-table (`table`). */
24
- readonly keyIdFor?: (table: string, rowId: string) => string;
23
+ /**
24
+ * Choose the key-id for an encrypt. The plaintext positional row is supplied
25
+ * so direct clients can select a Facility/workspace/key-grant key without
26
+ * deriving privacy-sensitive state from an opaque row id. Default: the
27
+ * configured `keyIdColumns[table]`, then the table name.
28
+ */
29
+ readonly keyIdFor?: (table: string, rowId: string, values: readonly RowValue[]) => string;
30
+ /**
31
+ * Portable declarative selector used by Worker/Tauri hosts. Each value names
32
+ * a non-encrypted string column whose row value is the active key id.
33
+ */
34
+ readonly keyIdColumns?: Readonly<Record<string, string>>;
25
35
  /**
26
36
  * Nonce source (§5.11). Production omits this (secure RNG). ONLY the crypto
27
37
  * golden-vector generator injects a fixed nonce — never a production path.
28
38
  */
29
39
  readonly nonceSource?: NonceSource;
30
40
  }
41
+ /** Structured-clone/JSON-safe keyring accepted by Worker and Tauri hosts. */
42
+ export interface EncryptionKeyringConfig {
43
+ readonly keys: Readonly<Record<string, Uint8Array>>;
44
+ readonly keyIdColumns?: Readonly<Record<string, string>>;
45
+ }
46
+ /** Convert a portable keyring into the direct-client encryption contract. */
47
+ export declare function encryptionConfigFromKeyring(keyring: EncryptionKeyringConfig): EncryptionConfig;
31
48
  /**
32
49
  * Encrypt the encrypted columns of a positional row value array in place-safe
33
50
  * fashion (returns a new array). Called at the outbox encode-at-send seam
@@ -9,6 +9,39 @@
9
9
  * envelope primitives.
10
10
  */
11
11
  import { DecryptError, decryptValue, encryptValue, } from '@syncular/core';
12
+ /** Convert a portable keyring into the direct-client encryption contract. */
13
+ export function encryptionConfigFromKeyring(keyring) {
14
+ return {
15
+ keyProvider: (keyId) => keyring.keys[keyId],
16
+ ...(keyring.keyIdColumns !== undefined
17
+ ? { keyIdColumns: keyring.keyIdColumns }
18
+ : {}),
19
+ };
20
+ }
21
+ function encryptionKeyId(config, table, rowId, values) {
22
+ if (config.keyIdFor !== undefined) {
23
+ const selected = config.keyIdFor(table.name, rowId, values);
24
+ if (selected.length === 0) {
25
+ throw new DecryptError(`empty encryption key id selected for table ${JSON.stringify(table.name)}`);
26
+ }
27
+ return selected;
28
+ }
29
+ const selectorColumn = config.keyIdColumns?.[table.name];
30
+ if (selectorColumn === undefined)
31
+ return table.name;
32
+ const index = table.columns.findIndex((column) => column.name === selectorColumn);
33
+ if (index < 0) {
34
+ throw new DecryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} is not present on table ${JSON.stringify(table.name)}`);
35
+ }
36
+ if (table.columns[index]?.encrypted === true) {
37
+ throw new DecryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must not be encrypted`);
38
+ }
39
+ const selected = values[index];
40
+ if (typeof selected !== 'string' || selected.length === 0) {
41
+ throw new DecryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must contain a non-empty string`);
42
+ }
43
+ return selected;
44
+ }
12
45
  function declaredTypeOf(column) {
13
46
  // An encrypted column always carries declaredType (typegen guarantees it;
14
47
  // §5.11). Fall back to the wire type defensively.
@@ -24,8 +57,8 @@ function declaredTypeOf(column) {
24
57
  export async function encryptRowValues(config, table, rowId, values) {
25
58
  if (!table.hasEncryptedColumns)
26
59
  return values.slice();
27
- const keyIdFor = config.keyIdFor ?? ((t) => t);
28
60
  const out = values.slice();
61
+ const keyId = encryptionKeyId(config, table, rowId, values);
29
62
  for (let i = 0; i < table.columns.length; i++) {
30
63
  const column = table.columns[i];
31
64
  if (column === undefined || !column.encrypted)
@@ -33,7 +66,6 @@ export async function encryptRowValues(config, table, rowId, values) {
33
66
  const value = out[i];
34
67
  if (value === null || value === undefined)
35
68
  continue; // NULL stays NULL
36
- const keyId = keyIdFor(table.name, rowId);
37
69
  const key = config.keyProvider(keyId);
38
70
  if (key === undefined) {
39
71
  throw new DecryptError(`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`);
package/dist/schema.js CHANGED
@@ -104,9 +104,6 @@ export function compileClientSchema(schema) {
104
104
  if (localColumnType(column) !== 'string') {
105
105
  throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} column ${JSON.stringify(columnName)} must have string type`);
106
106
  }
107
- if (column.encrypted === true) {
108
- throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} cannot index encrypted column ${JSON.stringify(columnName)}`);
109
- }
110
107
  }
111
108
  if (!ALLOWED_FTS_TOKENIZERS.has(index.tokenize)) {
112
109
  throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`);
@@ -14,6 +14,7 @@
14
14
  * the core owns exactly one loop.
15
15
  */
16
16
  import { SyncClient } from './client.js';
17
+ import { encryptionConfigFromKeyring } from './encryption.js';
17
18
  import { ClientSyncError } from './errors.js';
18
19
  import { httpBlobTransport, httpSegmentDownloader, httpSyncTransport, webSocketRealtimeConnector, } from './http.js';
19
20
  import { openPersistentWasmDatabase } from './wasm-database.js';
@@ -216,6 +217,9 @@ export function startSyncWorker(overrides = {}) {
216
217
  },
217
218
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
218
219
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
220
+ ...(config.encryption !== undefined
221
+ ? { encryption: encryptionConfigFromKeyring(config.encryption) }
222
+ : {}),
219
223
  onSyncNeeded: (reason) => {
220
224
  post({ t: 'event', event: { kind: 'sync-needed', reason } });
221
225
  consumeSyncIntent({ kind: 'interactive' });
@@ -25,6 +25,7 @@ import type { WakeReason } from '@syncular/core';
25
25
  import type { BlobRef, CachedBlob } from './blob.js';
26
26
  import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
27
27
  import type { SqlRow, SqlValue } from './database.js';
28
+ import type { EncryptionKeyringConfig } from './encryption.js';
28
29
  import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
29
30
  import { type LeaderLease, type LeaderLock } from './leader-lock.js';
30
31
  import { type CrossTabChannel, FollowerLink, LeaderBridge } from './multi-tab.js';
@@ -46,6 +47,8 @@ export interface SyncClientHandleConfig {
46
47
  readonly schema: ClientSchema;
47
48
  readonly database: WorkerDatabaseInit;
48
49
  readonly endpoints: WorkerEndpoints;
50
+ /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
51
+ readonly encryption?: EncryptionKeyringConfig;
49
52
  readonly clientId?: string;
50
53
  readonly limits?: SyncClientLimits;
51
54
  /** Worker-side host loop (§8.4); default true. */
@@ -367,6 +367,9 @@ function buildInitConfig(config) {
367
367
  schema: config.schema,
368
368
  database: config.database,
369
369
  endpoints: config.endpoints,
370
+ ...(config.encryption !== undefined
371
+ ? { encryption: config.encryption }
372
+ : {}),
370
373
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
371
374
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
372
375
  ...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
@@ -20,6 +20,7 @@ import type { WakeReason } from '@syncular/core';
20
20
  import type { BlobRef, CachedBlob } from './blob.js';
21
21
  import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
22
22
  import type { SqlRow, SqlValue } from './database.js';
23
+ import type { EncryptionKeyringConfig } from './encryption.js';
23
24
  import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
24
25
  import type { OutboxCommit } from './outbox.js';
25
26
  import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
@@ -61,6 +62,8 @@ export interface WorkerInitConfig {
61
62
  readonly schema: ClientSchema;
62
63
  readonly database: WorkerDatabaseInit;
63
64
  readonly endpoints: WorkerEndpoints;
65
+ /** Portable raw keyring installed inside the worker-owned client core. */
66
+ readonly encryption?: EncryptionKeyringConfig;
64
67
  readonly clientId?: string;
65
68
  readonly limits?: SyncClientLimits;
66
69
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.8",
3
+ "version": "0.15.9",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  "dependencies": {
83
83
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
84
- "@syncular/core": "0.15.8"
84
+ "@syncular/core": "0.15.9"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "better-sqlite3": ">=11"
@@ -92,7 +92,7 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.15.8",
95
+ "@syncular/server": "0.15.9",
96
96
  "@types/better-sqlite3": "^7.6.13",
97
97
  "better-sqlite3": "^12.11.1"
98
98
  }
package/src/encryption.ts CHANGED
@@ -30,8 +30,22 @@ import type { CompiledClientTable } from './schema';
30
30
  export interface EncryptionConfig {
31
31
  /** `keyId → 32-byte key`, or `undefined` if unknown (decrypt fails loud). */
32
32
  readonly keyProvider: (keyId: string) => Uint8Array | undefined;
33
- /** Choose the key-id for an encrypt. Default: per-table (`table`). */
34
- readonly keyIdFor?: (table: string, rowId: string) => string;
33
+ /**
34
+ * Choose the key-id for an encrypt. The plaintext positional row is supplied
35
+ * so direct clients can select a Facility/workspace/key-grant key without
36
+ * deriving privacy-sensitive state from an opaque row id. Default: the
37
+ * configured `keyIdColumns[table]`, then the table name.
38
+ */
39
+ readonly keyIdFor?: (
40
+ table: string,
41
+ rowId: string,
42
+ values: readonly RowValue[],
43
+ ) => string;
44
+ /**
45
+ * Portable declarative selector used by Worker/Tauri hosts. Each value names
46
+ * a non-encrypted string column whose row value is the active key id.
47
+ */
48
+ readonly keyIdColumns?: Readonly<Record<string, string>>;
35
49
  /**
36
50
  * Nonce source (§5.11). Production omits this (secure RNG). ONLY the crypto
37
51
  * golden-vector generator injects a fixed nonce — never a production path.
@@ -39,6 +53,63 @@ export interface EncryptionConfig {
39
53
  readonly nonceSource?: NonceSource;
40
54
  }
41
55
 
56
+ /** Structured-clone/JSON-safe keyring accepted by Worker and Tauri hosts. */
57
+ export interface EncryptionKeyringConfig {
58
+ readonly keys: Readonly<Record<string, Uint8Array>>;
59
+ readonly keyIdColumns?: Readonly<Record<string, string>>;
60
+ }
61
+
62
+ /** Convert a portable keyring into the direct-client encryption contract. */
63
+ export function encryptionConfigFromKeyring(
64
+ keyring: EncryptionKeyringConfig,
65
+ ): EncryptionConfig {
66
+ return {
67
+ keyProvider: (keyId) => keyring.keys[keyId],
68
+ ...(keyring.keyIdColumns !== undefined
69
+ ? { keyIdColumns: keyring.keyIdColumns }
70
+ : {}),
71
+ };
72
+ }
73
+
74
+ function encryptionKeyId(
75
+ config: EncryptionConfig,
76
+ table: CompiledClientTable,
77
+ rowId: string,
78
+ values: readonly RowValue[],
79
+ ): string {
80
+ if (config.keyIdFor !== undefined) {
81
+ const selected = config.keyIdFor(table.name, rowId, values);
82
+ if (selected.length === 0) {
83
+ throw new DecryptError(
84
+ `empty encryption key id selected for table ${JSON.stringify(table.name)}`,
85
+ );
86
+ }
87
+ return selected;
88
+ }
89
+ const selectorColumn = config.keyIdColumns?.[table.name];
90
+ if (selectorColumn === undefined) return table.name;
91
+ const index = table.columns.findIndex(
92
+ (column) => column.name === selectorColumn,
93
+ );
94
+ if (index < 0) {
95
+ throw new DecryptError(
96
+ `encryption key-id column ${JSON.stringify(selectorColumn)} is not present on table ${JSON.stringify(table.name)}`,
97
+ );
98
+ }
99
+ if (table.columns[index]?.encrypted === true) {
100
+ throw new DecryptError(
101
+ `encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must not be encrypted`,
102
+ );
103
+ }
104
+ const selected = values[index];
105
+ if (typeof selected !== 'string' || selected.length === 0) {
106
+ throw new DecryptError(
107
+ `encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must contain a non-empty string`,
108
+ );
109
+ }
110
+ return selected;
111
+ }
112
+
42
113
  function declaredTypeOf(column: RowColumn): DeclaredType {
43
114
  // An encrypted column always carries declaredType (typegen guarantees it;
44
115
  // §5.11). Fall back to the wire type defensively.
@@ -59,14 +130,13 @@ export async function encryptRowValues(
59
130
  values: readonly RowValue[],
60
131
  ): Promise<RowValue[]> {
61
132
  if (!table.hasEncryptedColumns) return values.slice();
62
- const keyIdFor = config.keyIdFor ?? ((t: string) => t);
63
133
  const out = values.slice();
134
+ const keyId = encryptionKeyId(config, table, rowId, values);
64
135
  for (let i = 0; i < table.columns.length; i++) {
65
136
  const column = table.columns[i];
66
137
  if (column === undefined || !column.encrypted) continue;
67
138
  const value = out[i];
68
139
  if (value === null || value === undefined) continue; // NULL stays NULL
69
- const keyId = keyIdFor(table.name, rowId);
70
140
  const key = config.keyProvider(keyId);
71
141
  if (key === undefined) {
72
142
  throw new DecryptError(
package/src/schema.ts CHANGED
@@ -216,11 +216,6 @@ export function compileClientSchema(
216
216
  `table ${table.name}: FTS projection ${JSON.stringify(index.name)} column ${JSON.stringify(columnName)} must have string type`,
217
217
  );
218
218
  }
219
- if (column.encrypted === true) {
220
- throw new Error(
221
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} cannot index encrypted column ${JSON.stringify(columnName)}`,
222
- );
223
- }
224
219
  }
225
220
  if (!ALLOWED_FTS_TOKENIZERS.has(index.tokenize)) {
226
221
  throw new Error(
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { SyncClient } from './client';
17
17
  import type { ClientDatabase } from './database';
18
+ import { encryptionConfigFromKeyring } from './encryption';
18
19
  import { ClientSyncError } from './errors';
19
20
  import {
20
21
  httpBlobTransport,
@@ -299,6 +300,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
299
300
  },
300
301
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
301
302
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
303
+ ...(config.encryption !== undefined
304
+ ? { encryption: encryptionConfigFromKeyring(config.encryption) }
305
+ : {}),
302
306
  onSyncNeeded: (reason) => {
303
307
  post({ t: 'event', event: { kind: 'sync-needed', reason } });
304
308
  consumeSyncIntent({ kind: 'interactive' });
@@ -39,6 +39,7 @@ import type {
39
39
  } from './client';
40
40
  import type { SqlRow, SqlValue } from './database';
41
41
  import { registerDevtools } from './devtools';
42
+ import type { EncryptionKeyringConfig } from './encryption';
42
43
  import { ClientSyncError } from './errors';
43
44
  import {
44
45
  ChangeEmitter,
@@ -100,6 +101,8 @@ export interface SyncClientHandleConfig {
100
101
  readonly schema: ClientSchema;
101
102
  readonly database: WorkerDatabaseInit;
102
103
  readonly endpoints: WorkerEndpoints;
104
+ /** Structured-clone-safe E2EE keyring installed only in the leader worker. */
105
+ readonly encryption?: EncryptionKeyringConfig;
103
106
  readonly clientId?: string;
104
107
  readonly limits?: SyncClientLimits;
105
108
  /** Worker-side host loop (§8.4); default true. */
@@ -624,6 +627,9 @@ function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
624
627
  schema: config.schema,
625
628
  database: config.database,
626
629
  endpoints: config.endpoints,
630
+ ...(config.encryption !== undefined
631
+ ? { encryption: config.encryption }
632
+ : {}),
627
633
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
628
634
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
629
635
  ...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
@@ -33,6 +33,7 @@ import type {
33
33
  WindowState,
34
34
  } from './client';
35
35
  import type { SqlRow, SqlValue } from './database';
36
+ import type { EncryptionKeyringConfig } from './encryption';
36
37
  import type {
37
38
  ClientChangeBatch,
38
39
  LocalRevision,
@@ -96,6 +97,8 @@ export interface WorkerInitConfig {
96
97
  readonly schema: ClientSchema;
97
98
  readonly database: WorkerDatabaseInit;
98
99
  readonly endpoints: WorkerEndpoints;
100
+ /** Portable raw keyring installed inside the worker-owned client core. */
101
+ readonly encryption?: EncryptionKeyringConfig;
99
102
  readonly clientId?: string;
100
103
  readonly limits?: SyncClientLimits;
101
104
  /**