@syncular/client 0.21.0 → 0.21.1
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/client.js +22 -4
- package/dist/encryption.d.ts +1 -1
- package/dist/encryption.js +35 -10
- package/dist/outbox.d.ts +3 -2
- package/dist/outbox.js +4 -3
- package/package.json +3 -3
- package/src/client.ts +33 -4
- package/src/encryption.ts +37 -13
- package/src/outbox.ts +13 -2
package/dist/client.js
CHANGED
|
@@ -9,6 +9,7 @@ import { ProgressEmitter, } from './progress.js';
|
|
|
9
9
|
* `sync()` round (§7.2); local reads go straight to the database.
|
|
10
10
|
*/
|
|
11
11
|
import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
12
|
+
import { EncryptError } from '@syncular/core';
|
|
12
13
|
import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
|
|
13
14
|
import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
|
|
14
15
|
import { registerDevtools } from './devtools.js';
|
|
@@ -1807,8 +1808,20 @@ export class SyncClient {
|
|
|
1807
1808
|
try {
|
|
1808
1809
|
pushFrames.push(
|
|
1809
1810
|
// §5.11: encrypted columns are encrypted at this encode-at-send
|
|
1810
|
-
// seam before the row codec serializes them.
|
|
1811
|
-
|
|
1811
|
+
// seam before the row codec serializes them. The stored local row
|
|
1812
|
+
// supplies an ABSENT key-id selector (§6.1 presence set first,
|
|
1813
|
+
// stored row second); an unresolvable key becomes a durable
|
|
1814
|
+
// rejection for that commit, never a sync() throw.
|
|
1815
|
+
await encodeOutboxCommit(this.#schema, commit, this.#encryption, (table, rowId) => {
|
|
1816
|
+
const selector = this.#encryption?.keyIdColumns?.[table.name];
|
|
1817
|
+
if (selector === undefined)
|
|
1818
|
+
return undefined;
|
|
1819
|
+
const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`, [rowId])[0];
|
|
1820
|
+
const value = row?.value;
|
|
1821
|
+
return typeof value === 'string' && value.length > 0
|
|
1822
|
+
? value
|
|
1823
|
+
: undefined;
|
|
1824
|
+
}));
|
|
1812
1825
|
outbox.push(commit);
|
|
1813
1826
|
ops += commit.operations.length;
|
|
1814
1827
|
}
|
|
@@ -1817,6 +1830,10 @@ export class SyncClient {
|
|
|
1817
1830
|
this.#dropIncompatibleCommit(commit, error.message);
|
|
1818
1831
|
continue;
|
|
1819
1832
|
}
|
|
1833
|
+
if (error instanceof EncryptError) {
|
|
1834
|
+
this.#dropIncompatibleCommit(commit, error.message, error.code);
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1820
1837
|
throw error;
|
|
1821
1838
|
}
|
|
1822
1839
|
}
|
|
@@ -1826,14 +1843,15 @@ export class SyncClient {
|
|
|
1826
1843
|
* §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
|
|
1827
1844
|
* §7.2 `rejected` surface — the commit leaves the outbox, its
|
|
1828
1845
|
* purely-optimistic rows are undone, and a rejection record is raised.
|
|
1846
|
+
* §5.11: the same drop carries an encode-seam `client.encrypt_failed`.
|
|
1829
1847
|
*/
|
|
1830
|
-
#dropIncompatibleCommit(commit, message) {
|
|
1848
|
+
#dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE) {
|
|
1831
1849
|
this.#applyBatch((batch) => {
|
|
1832
1850
|
this.#rollbackFailedCommit(commit, batch);
|
|
1833
1851
|
const rejection = {
|
|
1834
1852
|
clientCommitId: commit.clientCommitId,
|
|
1835
1853
|
opIndex: 0,
|
|
1836
|
-
code
|
|
1854
|
+
code,
|
|
1837
1855
|
message,
|
|
1838
1856
|
retryable: false,
|
|
1839
1857
|
...(commit.operations[0] !== undefined
|
package/dist/encryption.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export declare function encryptionConfigFromKeyring(keyring: EncryptionKeyringCo
|
|
|
57
57
|
* value becomes the §5.11 ciphertext-envelope `bytes`. NULLs pass through
|
|
58
58
|
* unencrypted (§5.11).
|
|
59
59
|
*/
|
|
60
|
-
export declare function encryptRowValues<T extends RowValue | undefined>(config: EncryptionConfig, table: CompiledClientTable, rowId: string, values: readonly T[]): Promise<T[]>;
|
|
60
|
+
export declare function encryptRowValues<T extends RowValue | undefined>(config: EncryptionConfig, table: CompiledClientTable, rowId: string, values: readonly T[], storedKeyId?: string): Promise<T[]>;
|
|
61
61
|
/**
|
|
62
62
|
* Decrypt the encrypted columns of a decoded positional row value array
|
|
63
63
|
* (returns a new array). Called at the apply seam (§4.5 COMMIT, §5.6 rows
|
package/dist/encryption.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* bridge between the positional `RowValue[]` and the `@syncular/core` §5.11
|
|
9
9
|
* envelope primitives.
|
|
10
10
|
*/
|
|
11
|
-
import { DecryptError, decryptValue, encryptValue, KEY_LENGTH, } from '@syncular/core';
|
|
11
|
+
import { DecryptError, decryptValue, EncryptError, encryptValue, KEY_LENGTH, } from '@syncular/core';
|
|
12
12
|
import { ClientSyncError } from './errors.js';
|
|
13
13
|
/**
|
|
14
14
|
* Convert a portable keyring into the direct-client encryption contract.
|
|
@@ -36,7 +36,7 @@ function encryptionKeyId(config, table, rowId, values) {
|
|
|
36
36
|
if (config.keyIdFor !== undefined) {
|
|
37
37
|
const selected = config.keyIdFor(table.name, rowId, values);
|
|
38
38
|
if (selected.length === 0) {
|
|
39
|
-
throw new
|
|
39
|
+
throw new EncryptError(`empty encryption key id selected for table ${JSON.stringify(table.name)}`);
|
|
40
40
|
}
|
|
41
41
|
return selected;
|
|
42
42
|
}
|
|
@@ -45,14 +45,14 @@ function encryptionKeyId(config, table, rowId, values) {
|
|
|
45
45
|
return table.name;
|
|
46
46
|
const index = table.columns.findIndex((column) => column.name === selectorColumn);
|
|
47
47
|
if (index < 0) {
|
|
48
|
-
throw new
|
|
48
|
+
throw new EncryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} is not present on table ${JSON.stringify(table.name)}`);
|
|
49
49
|
}
|
|
50
50
|
if (table.columns[index]?.encrypted === true) {
|
|
51
|
-
throw new
|
|
51
|
+
throw new EncryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must not be encrypted`);
|
|
52
52
|
}
|
|
53
53
|
const selected = values[index];
|
|
54
54
|
if (typeof selected !== 'string' || selected.length === 0) {
|
|
55
|
-
throw new
|
|
55
|
+
throw new EncryptError(`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must contain a non-empty string`);
|
|
56
56
|
}
|
|
57
57
|
return selected;
|
|
58
58
|
}
|
|
@@ -68,13 +68,38 @@ function declaredTypeOf(column) {
|
|
|
68
68
|
* value becomes the §5.11 ciphertext-envelope `bytes`. NULLs pass through
|
|
69
69
|
* unencrypted (§5.11).
|
|
70
70
|
*/
|
|
71
|
-
export async function encryptRowValues(config, table, rowId, values) {
|
|
71
|
+
export async function encryptRowValues(config, table, rowId, values, storedKeyId) {
|
|
72
72
|
if (!table.hasEncryptedColumns)
|
|
73
73
|
return values.slice();
|
|
74
74
|
const out = values.slice();
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
75
|
+
// Sparse semantics: absent (undefined) and NULL columns are never
|
|
76
|
+
// re-encrypted, so a patch that presents no encrypted value needs no key.
|
|
77
|
+
let hasPresentEncrypted = false;
|
|
78
|
+
for (let i = 0; i < table.columns.length; i++) {
|
|
79
|
+
if (table.columns[i]?.encrypted !== true)
|
|
80
|
+
continue;
|
|
81
|
+
const value = out[i];
|
|
82
|
+
if (value !== undefined && value !== null) {
|
|
83
|
+
hasPresentEncrypted = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!hasPresentEncrypted)
|
|
88
|
+
return out;
|
|
89
|
+
// Key selection reads the presence set first, then the stored local row.
|
|
90
|
+
// Absent slots densify to null for selection only; the loop below still
|
|
91
|
+
// leaves them untouched. Absent ≠ NULL (§6.1): only an ABSENT selector
|
|
92
|
+
// slot is filled from the stored row, never a present NULL.
|
|
93
|
+
const selected = values.map((value) => value ?? null);
|
|
94
|
+
const selectorColumn = config.keyIdColumns?.[table.name];
|
|
95
|
+
if (selectorColumn !== undefined &&
|
|
96
|
+
config.keyIdFor === undefined &&
|
|
97
|
+
storedKeyId !== undefined) {
|
|
98
|
+
const index = table.columns.findIndex((column) => column.name === selectorColumn);
|
|
99
|
+
if (index >= 0 && values[index] === undefined)
|
|
100
|
+
selected[index] = storedKeyId;
|
|
101
|
+
}
|
|
102
|
+
const keyId = encryptionKeyId(config, table, rowId, selected);
|
|
78
103
|
for (let i = 0; i < table.columns.length; i++) {
|
|
79
104
|
const column = table.columns[i];
|
|
80
105
|
if (column === undefined || !column.encrypted)
|
|
@@ -84,7 +109,7 @@ export async function encryptRowValues(config, table, rowId, values) {
|
|
|
84
109
|
continue; // NULL stays NULL
|
|
85
110
|
const key = config.keyProvider(keyId);
|
|
86
111
|
if (key === undefined) {
|
|
87
|
-
throw new
|
|
112
|
+
throw new EncryptError(`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`);
|
|
88
113
|
}
|
|
89
114
|
out[i] = (await encryptValue(declaredTypeOf(column), value, keyId, key, config.nonceSource));
|
|
90
115
|
}
|
package/dist/outbox.d.ts
CHANGED
|
@@ -61,9 +61,10 @@ export declare class OutboxEncodeError extends ClientSyncError {
|
|
|
61
61
|
* schema's row codec (§6.1). When `encryption` is configured, encrypted
|
|
62
62
|
* columns (§5.11) are encrypted here — the encode-at-send seam — before the
|
|
63
63
|
* row codec serializes them as ciphertext-envelope `bytes`. Async because
|
|
64
|
-
* WebCrypto is async.
|
|
64
|
+
* WebCrypto is async. `storedKeyIdFor` supplies the stored local row's
|
|
65
|
+
* key-id selector for a sparse upsert that omits that column (§5.11).
|
|
65
66
|
*/
|
|
66
|
-
export declare function encodeOutboxCommit(schema: CompiledClientSchema, commit: OutboxCommit, encryption?: EncryptionConfig): Promise<PushCommitFrame>;
|
|
67
|
+
export declare function encodeOutboxCommit(schema: CompiledClientSchema, commit: OutboxCommit, encryption?: EncryptionConfig, storedKeyIdFor?: (table: CompiledClientTable, rowId: string) => string | undefined): Promise<PushCommitFrame>;
|
|
67
68
|
/**
|
|
68
69
|
* §3.3: drop pending commits that write into a revoked scope instead of
|
|
69
70
|
* replaying them into guaranteed rejections. A whole commit is dropped when
|
package/dist/outbox.js
CHANGED
|
@@ -132,9 +132,10 @@ function sparseValues(table, values) {
|
|
|
132
132
|
* schema's row codec (§6.1). When `encryption` is configured, encrypted
|
|
133
133
|
* columns (§5.11) are encrypted here — the encode-at-send seam — before the
|
|
134
134
|
* row codec serializes them as ciphertext-envelope `bytes`. Async because
|
|
135
|
-
* WebCrypto is async.
|
|
135
|
+
* WebCrypto is async. `storedKeyIdFor` supplies the stored local row's
|
|
136
|
+
* key-id selector for a sparse upsert that omits that column (§5.11).
|
|
136
137
|
*/
|
|
137
|
-
export async function encodeOutboxCommit(schema, commit, encryption) {
|
|
138
|
+
export async function encodeOutboxCommit(schema, commit, encryption, storedKeyIdFor) {
|
|
138
139
|
const operations = [];
|
|
139
140
|
for (const op of commit.operations) {
|
|
140
141
|
if (op.op === 'delete') {
|
|
@@ -160,7 +161,7 @@ export async function encodeOutboxCommit(schema, commit, encryption) {
|
|
|
160
161
|
if (encryption !== undefined && table.hasEncryptedColumns) {
|
|
161
162
|
// Lazy: opt-in E2EE never enters an encryption-free app's bundle.
|
|
162
163
|
const { encryptRowValues } = await import('./encryption.js');
|
|
163
|
-
values = await encryptRowValues(encryption, table, op.rowId, values);
|
|
164
|
+
values = await encryptRowValues(encryption, table, op.rowId, values, storedKeyIdFor?.(table, op.rowId));
|
|
164
165
|
}
|
|
165
166
|
operations.push({
|
|
166
167
|
table: op.table,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.1",
|
|
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",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.21.
|
|
92
|
+
"@syncular/core": "0.21.1"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.21.
|
|
95
|
+
"@syncular/server": "0.21.1",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type SubStartFrame,
|
|
38
38
|
type WakeReason,
|
|
39
39
|
} from '@syncular/core';
|
|
40
|
+
import { EncryptError } from '@syncular/core';
|
|
40
41
|
import {
|
|
41
42
|
applyCommitFrame,
|
|
42
43
|
applyRowsSegment,
|
|
@@ -2638,8 +2639,27 @@ export class SyncClient {
|
|
|
2638
2639
|
try {
|
|
2639
2640
|
pushFrames.push(
|
|
2640
2641
|
// §5.11: encrypted columns are encrypted at this encode-at-send
|
|
2641
|
-
// seam before the row codec serializes them.
|
|
2642
|
-
|
|
2642
|
+
// seam before the row codec serializes them. The stored local row
|
|
2643
|
+
// supplies an ABSENT key-id selector (§6.1 presence set first,
|
|
2644
|
+
// stored row second); an unresolvable key becomes a durable
|
|
2645
|
+
// rejection for that commit, never a sync() throw.
|
|
2646
|
+
await encodeOutboxCommit(
|
|
2647
|
+
this.#schema,
|
|
2648
|
+
commit,
|
|
2649
|
+
this.#encryption,
|
|
2650
|
+
(table, rowId) => {
|
|
2651
|
+
const selector = this.#encryption?.keyIdColumns?.[table.name];
|
|
2652
|
+
if (selector === undefined) return undefined;
|
|
2653
|
+
const row = this.#db.query(
|
|
2654
|
+
`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = ?`,
|
|
2655
|
+
[rowId],
|
|
2656
|
+
)[0];
|
|
2657
|
+
const value = row?.value;
|
|
2658
|
+
return typeof value === 'string' && value.length > 0
|
|
2659
|
+
? value
|
|
2660
|
+
: undefined;
|
|
2661
|
+
},
|
|
2662
|
+
),
|
|
2643
2663
|
);
|
|
2644
2664
|
outbox.push(commit);
|
|
2645
2665
|
ops += commit.operations.length;
|
|
@@ -2648,6 +2668,10 @@ export class SyncClient {
|
|
|
2648
2668
|
this.#dropIncompatibleCommit(commit, error.message);
|
|
2649
2669
|
continue;
|
|
2650
2670
|
}
|
|
2671
|
+
if (error instanceof EncryptError) {
|
|
2672
|
+
this.#dropIncompatibleCommit(commit, error.message, error.code);
|
|
2673
|
+
continue;
|
|
2674
|
+
}
|
|
2651
2675
|
throw error;
|
|
2652
2676
|
}
|
|
2653
2677
|
}
|
|
@@ -2658,14 +2682,19 @@ export class SyncClient {
|
|
|
2658
2682
|
* §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
|
|
2659
2683
|
* §7.2 `rejected` surface — the commit leaves the outbox, its
|
|
2660
2684
|
* purely-optimistic rows are undone, and a rejection record is raised.
|
|
2685
|
+
* §5.11: the same drop carries an encode-seam `client.encrypt_failed`.
|
|
2661
2686
|
*/
|
|
2662
|
-
#dropIncompatibleCommit(
|
|
2687
|
+
#dropIncompatibleCommit(
|
|
2688
|
+
commit: OutboxCommit,
|
|
2689
|
+
message: string,
|
|
2690
|
+
code: string = OUTBOX_INCOMPATIBLE_CODE,
|
|
2691
|
+
): void {
|
|
2663
2692
|
this.#applyBatch((batch) => {
|
|
2664
2693
|
this.#rollbackFailedCommit(commit, batch);
|
|
2665
2694
|
const rejection: RejectionRecord = {
|
|
2666
2695
|
clientCommitId: commit.clientCommitId,
|
|
2667
2696
|
opIndex: 0,
|
|
2668
|
-
code
|
|
2697
|
+
code,
|
|
2669
2698
|
message,
|
|
2670
2699
|
retryable: false,
|
|
2671
2700
|
...(commit.operations[0] !== undefined
|
package/src/encryption.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type DeclaredType,
|
|
13
13
|
DecryptError,
|
|
14
14
|
decryptValue,
|
|
15
|
+
EncryptError,
|
|
15
16
|
encryptValue,
|
|
16
17
|
KEY_LENGTH,
|
|
17
18
|
type NonceSource,
|
|
@@ -99,7 +100,7 @@ function encryptionKeyId(
|
|
|
99
100
|
if (config.keyIdFor !== undefined) {
|
|
100
101
|
const selected = config.keyIdFor(table.name, rowId, values);
|
|
101
102
|
if (selected.length === 0) {
|
|
102
|
-
throw new
|
|
103
|
+
throw new EncryptError(
|
|
103
104
|
`empty encryption key id selected for table ${JSON.stringify(table.name)}`,
|
|
104
105
|
);
|
|
105
106
|
}
|
|
@@ -111,18 +112,18 @@ function encryptionKeyId(
|
|
|
111
112
|
(column) => column.name === selectorColumn,
|
|
112
113
|
);
|
|
113
114
|
if (index < 0) {
|
|
114
|
-
throw new
|
|
115
|
+
throw new EncryptError(
|
|
115
116
|
`encryption key-id column ${JSON.stringify(selectorColumn)} is not present on table ${JSON.stringify(table.name)}`,
|
|
116
117
|
);
|
|
117
118
|
}
|
|
118
119
|
if (table.columns[index]?.encrypted === true) {
|
|
119
|
-
throw new
|
|
120
|
+
throw new EncryptError(
|
|
120
121
|
`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must not be encrypted`,
|
|
121
122
|
);
|
|
122
123
|
}
|
|
123
124
|
const selected = values[index];
|
|
124
125
|
if (typeof selected !== 'string' || selected.length === 0) {
|
|
125
|
-
throw new
|
|
126
|
+
throw new EncryptError(
|
|
126
127
|
`encryption key-id column ${JSON.stringify(selectorColumn)} on table ${JSON.stringify(table.name)} must contain a non-empty string`,
|
|
127
128
|
);
|
|
128
129
|
}
|
|
@@ -147,17 +148,40 @@ export async function encryptRowValues<T extends RowValue | undefined>(
|
|
|
147
148
|
table: CompiledClientTable,
|
|
148
149
|
rowId: string,
|
|
149
150
|
values: readonly T[],
|
|
151
|
+
storedKeyId?: string,
|
|
150
152
|
): Promise<T[]> {
|
|
151
153
|
if (!table.hasEncryptedColumns) return values.slice();
|
|
152
154
|
const out = values.slice();
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
table
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
155
|
+
// Sparse semantics: absent (undefined) and NULL columns are never
|
|
156
|
+
// re-encrypted, so a patch that presents no encrypted value needs no key.
|
|
157
|
+
let hasPresentEncrypted = false;
|
|
158
|
+
for (let i = 0; i < table.columns.length; i++) {
|
|
159
|
+
if (table.columns[i]?.encrypted !== true) continue;
|
|
160
|
+
const value = out[i];
|
|
161
|
+
if (value !== undefined && value !== null) {
|
|
162
|
+
hasPresentEncrypted = true;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!hasPresentEncrypted) return out;
|
|
167
|
+
// Key selection reads the presence set first, then the stored local row.
|
|
168
|
+
// Absent slots densify to null for selection only; the loop below still
|
|
169
|
+
// leaves them untouched. Absent ≠ NULL (§6.1): only an ABSENT selector
|
|
170
|
+
// slot is filled from the stored row, never a present NULL.
|
|
171
|
+
const selected: RowValue[] = values.map((value) => value ?? null);
|
|
172
|
+
const selectorColumn = config.keyIdColumns?.[table.name];
|
|
173
|
+
if (
|
|
174
|
+
selectorColumn !== undefined &&
|
|
175
|
+
config.keyIdFor === undefined &&
|
|
176
|
+
storedKeyId !== undefined
|
|
177
|
+
) {
|
|
178
|
+
const index = table.columns.findIndex(
|
|
179
|
+
(column) => column.name === selectorColumn,
|
|
180
|
+
);
|
|
181
|
+
if (index >= 0 && values[index] === undefined)
|
|
182
|
+
selected[index] = storedKeyId;
|
|
183
|
+
}
|
|
184
|
+
const keyId = encryptionKeyId(config, table, rowId, selected);
|
|
161
185
|
for (let i = 0; i < table.columns.length; i++) {
|
|
162
186
|
const column = table.columns[i];
|
|
163
187
|
if (column === undefined || !column.encrypted) continue;
|
|
@@ -165,7 +189,7 @@ export async function encryptRowValues<T extends RowValue | undefined>(
|
|
|
165
189
|
if (value === null || value === undefined) continue; // NULL stays NULL
|
|
166
190
|
const key = config.keyProvider(keyId);
|
|
167
191
|
if (key === undefined) {
|
|
168
|
-
throw new
|
|
192
|
+
throw new EncryptError(
|
|
169
193
|
`no encryption key for keyId ${JSON.stringify(keyId)} (table ${table.name})`,
|
|
170
194
|
);
|
|
171
195
|
}
|
package/src/outbox.ts
CHANGED
|
@@ -237,12 +237,17 @@ function sparseValues(
|
|
|
237
237
|
* schema's row codec (§6.1). When `encryption` is configured, encrypted
|
|
238
238
|
* columns (§5.11) are encrypted here — the encode-at-send seam — before the
|
|
239
239
|
* row codec serializes them as ciphertext-envelope `bytes`. Async because
|
|
240
|
-
* WebCrypto is async.
|
|
240
|
+
* WebCrypto is async. `storedKeyIdFor` supplies the stored local row's
|
|
241
|
+
* key-id selector for a sparse upsert that omits that column (§5.11).
|
|
241
242
|
*/
|
|
242
243
|
export async function encodeOutboxCommit(
|
|
243
244
|
schema: CompiledClientSchema,
|
|
244
245
|
commit: OutboxCommit,
|
|
245
246
|
encryption?: EncryptionConfig,
|
|
247
|
+
storedKeyIdFor?: (
|
|
248
|
+
table: CompiledClientTable,
|
|
249
|
+
rowId: string,
|
|
250
|
+
) => string | undefined,
|
|
246
251
|
): Promise<PushCommitFrame> {
|
|
247
252
|
const operations: PushOperation[] = [];
|
|
248
253
|
for (const op of commit.operations) {
|
|
@@ -274,7 +279,13 @@ export async function encodeOutboxCommit(
|
|
|
274
279
|
if (encryption !== undefined && table.hasEncryptedColumns) {
|
|
275
280
|
// Lazy: opt-in E2EE never enters an encryption-free app's bundle.
|
|
276
281
|
const { encryptRowValues } = await import('./encryption');
|
|
277
|
-
values = await encryptRowValues(
|
|
282
|
+
values = await encryptRowValues(
|
|
283
|
+
encryption,
|
|
284
|
+
table,
|
|
285
|
+
op.rowId,
|
|
286
|
+
values,
|
|
287
|
+
storedKeyIdFor?.(table, op.rowId),
|
|
288
|
+
);
|
|
278
289
|
}
|
|
279
290
|
operations.push({
|
|
280
291
|
table: op.table,
|