@proteinjs/db 1.35.0 → 1.36.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.
Files changed (161) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/generated/index.d.ts.map +1 -1
  3. package/dist/generated/index.js +7 -1
  4. package/dist/generated/index.js.map +1 -1
  5. package/dist/generated/test/index.d.ts.map +1 -1
  6. package/dist/generated/test/index.js +7 -1
  7. package/dist/generated/test/index.js.map +1 -1
  8. package/dist/index.d.ts +15 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +15 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/src/Db.d.ts +25 -0
  13. package/dist/src/Db.d.ts.map +1 -1
  14. package/dist/src/Db.js +219 -22
  15. package/dist/src/Db.js.map +1 -1
  16. package/dist/src/Record.d.ts +7 -1
  17. package/dist/src/Record.d.ts.map +1 -1
  18. package/dist/src/Record.js +56 -21
  19. package/dist/src/Record.js.map +1 -1
  20. package/dist/src/Table.d.ts +82 -1
  21. package/dist/src/Table.d.ts.map +1 -1
  22. package/dist/src/Table.js +24 -1
  23. package/dist/src/Table.js.map +1 -1
  24. package/dist/src/TableQueryTransformProvider.d.ts +17 -0
  25. package/dist/src/TableQueryTransformProvider.d.ts.map +1 -0
  26. package/dist/src/TableQueryTransformProvider.js +34 -0
  27. package/dist/src/TableQueryTransformProvider.js.map +1 -0
  28. package/dist/src/encryption/Base64Url.d.ts +12 -0
  29. package/dist/src/encryption/Base64Url.d.ts.map +1 -0
  30. package/dist/src/encryption/Base64Url.js +21 -0
  31. package/dist/src/encryption/Base64Url.js.map +1 -0
  32. package/dist/src/encryption/DataEncryptionKeyTable.d.ts +33 -0
  33. package/dist/src/encryption/DataEncryptionKeyTable.d.ts.map +1 -0
  34. package/dist/src/encryption/DataEncryptionKeyTable.js +45 -0
  35. package/dist/src/encryption/DataEncryptionKeyTable.js.map +1 -0
  36. package/dist/src/encryption/DataKeyStore.d.ts +84 -0
  37. package/dist/src/encryption/DataKeyStore.d.ts.map +1 -0
  38. package/dist/src/encryption/DataKeyStore.js +481 -0
  39. package/dist/src/encryption/DataKeyStore.js.map +1 -0
  40. package/dist/src/encryption/DbEncryptionConfig.d.ts +62 -0
  41. package/dist/src/encryption/DbEncryptionConfig.d.ts.map +1 -0
  42. package/dist/src/encryption/DbEncryptionConfig.js +67 -0
  43. package/dist/src/encryption/DbEncryptionConfig.js.map +1 -0
  44. package/dist/src/encryption/EncryptedColumnQueryError.d.ts +10 -0
  45. package/dist/src/encryption/EncryptedColumnQueryError.d.ts.map +1 -0
  46. package/dist/src/encryption/EncryptedColumnQueryError.js +38 -0
  47. package/dist/src/encryption/EncryptedColumnQueryError.js.map +1 -0
  48. package/dist/src/encryption/EncryptedColumnQueryTransform.d.ts +52 -0
  49. package/dist/src/encryption/EncryptedColumnQueryTransform.d.ts.map +1 -0
  50. package/dist/src/encryption/EncryptedColumnQueryTransform.js +423 -0
  51. package/dist/src/encryption/EncryptedColumnQueryTransform.js.map +1 -0
  52. package/dist/src/encryption/EncryptedColumns.d.ts +69 -0
  53. package/dist/src/encryption/EncryptedColumns.d.ts.map +1 -0
  54. package/dist/src/encryption/EncryptedColumns.js +286 -0
  55. package/dist/src/encryption/EncryptedColumns.js.map +1 -0
  56. package/dist/src/encryption/EncryptionDerivedTableRegistry.d.ts +20 -0
  57. package/dist/src/encryption/EncryptionDerivedTableRegistry.d.ts.map +1 -0
  58. package/dist/src/encryption/EncryptionDerivedTableRegistry.js +36 -0
  59. package/dist/src/encryption/EncryptionDerivedTableRegistry.js.map +1 -0
  60. package/dist/src/encryption/EncryptionEnvelope.d.ts +25 -0
  61. package/dist/src/encryption/EncryptionEnvelope.d.ts.map +1 -0
  62. package/dist/src/encryption/EncryptionEnvelope.js +59 -0
  63. package/dist/src/encryption/EncryptionEnvelope.js.map +1 -0
  64. package/dist/src/encryption/EncryptionLifecycleWalker.d.ts +65 -0
  65. package/dist/src/encryption/EncryptionLifecycleWalker.d.ts.map +1 -0
  66. package/dist/src/encryption/EncryptionLifecycleWalker.js +343 -0
  67. package/dist/src/encryption/EncryptionLifecycleWalker.js.map +1 -0
  68. package/dist/src/encryption/EncryptionRecordHooks.d.ts +47 -0
  69. package/dist/src/encryption/EncryptionRecordHooks.d.ts.map +1 -0
  70. package/dist/src/encryption/EncryptionRecordHooks.js +229 -0
  71. package/dist/src/encryption/EncryptionRecordHooks.js.map +1 -0
  72. package/dist/src/encryption/EncryptionTokenMaintenance.d.ts +34 -0
  73. package/dist/src/encryption/EncryptionTokenMaintenance.d.ts.map +1 -0
  74. package/dist/src/encryption/EncryptionTokenMaintenance.js +211 -0
  75. package/dist/src/encryption/EncryptionTokenMaintenance.js.map +1 -0
  76. package/dist/src/encryption/InMemoryMasterKeyProvider.d.ts +20 -0
  77. package/dist/src/encryption/InMemoryMasterKeyProvider.d.ts.map +1 -0
  78. package/dist/src/encryption/InMemoryMasterKeyProvider.js +87 -0
  79. package/dist/src/encryption/InMemoryMasterKeyProvider.js.map +1 -0
  80. package/dist/src/encryption/MasterKeyProvider.d.ts +27 -0
  81. package/dist/src/encryption/MasterKeyProvider.d.ts.map +1 -0
  82. package/dist/src/encryption/MasterKeyProvider.js +3 -0
  83. package/dist/src/encryption/MasterKeyProvider.js.map +1 -0
  84. package/dist/src/encryption/SearchTokenizer.d.ts +49 -0
  85. package/dist/src/encryption/SearchTokenizer.d.ts.map +1 -0
  86. package/dist/src/encryption/SearchTokenizer.js +106 -0
  87. package/dist/src/encryption/SearchTokenizer.js.map +1 -0
  88. package/dist/src/schema/TableManager.d.ts +10 -0
  89. package/dist/src/schema/TableManager.d.ts.map +1 -1
  90. package/dist/src/schema/TableManager.js +62 -5
  91. package/dist/src/schema/TableManager.js.map +1 -1
  92. package/dist/src/tables/MigrationTable.d.ts +2 -2
  93. package/dist/test/EncryptedColumnsSchema.test.d.ts +2 -0
  94. package/dist/test/EncryptedColumnsSchema.test.d.ts.map +1 -0
  95. package/dist/test/EncryptedColumnsSchema.test.js +151 -0
  96. package/dist/test/EncryptedColumnsSchema.test.js.map +1 -0
  97. package/dist/test/EncryptionEnvelope.test.d.ts +2 -0
  98. package/dist/test/EncryptionEnvelope.test.d.ts.map +1 -0
  99. package/dist/test/EncryptionEnvelope.test.js +109 -0
  100. package/dist/test/EncryptionEnvelope.test.js.map +1 -0
  101. package/dist/test/EncryptionQueryContract.test.d.ts +2 -0
  102. package/dist/test/EncryptionQueryContract.test.d.ts.map +1 -0
  103. package/dist/test/EncryptionQueryContract.test.js +399 -0
  104. package/dist/test/EncryptionQueryContract.test.js.map +1 -0
  105. package/dist/test/SearchTokenizer.test.d.ts +2 -0
  106. package/dist/test/SearchTokenizer.test.d.ts.map +1 -0
  107. package/dist/test/SearchTokenizer.test.js +64 -0
  108. package/dist/test/SearchTokenizer.test.js.map +1 -0
  109. package/dist/test/reusable/CascadeDeleteTests.js +2 -2
  110. package/dist/test/reusable/CascadeDeleteTests.js.map +1 -1
  111. package/dist/test/reusable/ColumnTypesTests.js +2 -2
  112. package/dist/test/reusable/ColumnTypesTests.js.map +1 -1
  113. package/dist/test/reusable/CrudTests.js +2 -2
  114. package/dist/test/reusable/CrudTests.js.map +1 -1
  115. package/dist/test/reusable/DynamicReferenceColumnTests.js +2 -2
  116. package/dist/test/reusable/DynamicReferenceColumnTests.js.map +1 -1
  117. package/dist/test/reusable/PreloadReferencesTests.js +2 -2
  118. package/dist/test/reusable/PreloadReferencesTests.js.map +1 -1
  119. package/dist/test/reusable/RecordIteratorTests.js +2 -2
  120. package/dist/test/reusable/RecordIteratorTests.js.map +1 -1
  121. package/dist/test/reusable/TableManagerTests.js +2 -2
  122. package/dist/test/reusable/TableManagerTests.js.map +1 -1
  123. package/dist/test/reusable/TransactionTests.js +2 -2
  124. package/dist/test/reusable/TransactionTests.js.map +1 -1
  125. package/generated/index.ts +7 -1
  126. package/generated/test/index.ts +7 -1
  127. package/index.ts +16 -0
  128. package/package.json +3 -3
  129. package/src/Db.ts +137 -12
  130. package/src/Record.ts +21 -4
  131. package/src/Table.ts +109 -2
  132. package/src/TableQueryTransformProvider.ts +31 -0
  133. package/src/encryption/Base64Url.ts +14 -0
  134. package/src/encryption/DataEncryptionKeyTable.ts +40 -0
  135. package/src/encryption/DataKeyStore.ts +305 -0
  136. package/src/encryption/DbEncryptionConfig.ts +92 -0
  137. package/src/encryption/EncryptedColumnQueryError.ts +15 -0
  138. package/src/encryption/EncryptedColumnQueryTransform.ts +383 -0
  139. package/src/encryption/EncryptedColumns.ts +318 -0
  140. package/src/encryption/EncryptionDerivedTableRegistry.ts +34 -0
  141. package/src/encryption/EncryptionEnvelope.ts +60 -0
  142. package/src/encryption/EncryptionLifecycleWalker.ts +243 -0
  143. package/src/encryption/EncryptionRecordHooks.ts +167 -0
  144. package/src/encryption/EncryptionTokenMaintenance.ts +123 -0
  145. package/src/encryption/InMemoryMasterKeyProvider.ts +42 -0
  146. package/src/encryption/MasterKeyProvider.ts +26 -0
  147. package/src/encryption/SearchTokenizer.ts +103 -0
  148. package/src/schema/TableManager.ts +43 -1
  149. package/test/EncryptedColumnsSchema.test.ts +172 -0
  150. package/test/EncryptionEnvelope.test.ts +66 -0
  151. package/test/EncryptionQueryContract.test.ts +195 -0
  152. package/test/SearchTokenizer.test.ts +67 -0
  153. package/test/reusable/CascadeDeleteTests.ts +2 -2
  154. package/test/reusable/ColumnTypesTests.ts +2 -2
  155. package/test/reusable/CrudTests.ts +2 -2
  156. package/test/reusable/DynamicReferenceColumnTests.ts +2 -2
  157. package/test/reusable/PreloadReferencesTests.ts +2 -2
  158. package/test/reusable/RecordIteratorTests.ts +2 -2
  159. package/test/reusable/TableManagerTests.ts +2 -2
  160. package/test/reusable/TransactionTests.ts +2 -2
  161. package/tsconfig.json +1 -1
package/index.ts CHANGED
@@ -29,6 +29,22 @@ export * from './src/schema/SchemaOperations';
29
29
  export * from './src/schema/SchemaMetadata';
30
30
  export * from './src/schema/TableManager';
31
31
 
32
+ export * from './src/encryption/MasterKeyProvider';
33
+ export * from './src/encryption/InMemoryMasterKeyProvider';
34
+ export * from './src/encryption/DbEncryptionConfig';
35
+ export * from './src/encryption/DataEncryptionKeyTable';
36
+ export * from './src/encryption/DataKeyStore';
37
+ export * from './src/encryption/EncryptionEnvelope';
38
+ export * from './src/encryption/SearchTokenizer';
39
+ export * from './src/encryption/EncryptedColumns';
40
+ export * from './src/encryption/EncryptedColumnQueryError';
41
+ export * from './src/encryption/EncryptedColumnQueryTransform';
42
+ export * from './src/TableQueryTransformProvider';
43
+ export * from './src/encryption/EncryptionRecordHooks';
44
+ export * from './src/encryption/EncryptionTokenMaintenance';
45
+ export * from './src/encryption/EncryptionLifecycleWalker';
46
+ export * from './src/encryption/EncryptionDerivedTableRegistry';
47
+
32
48
  export * from './src/tables/tables';
33
49
  export * from './src/tables/MigrationTable';
34
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -41,7 +41,7 @@
41
41
  "test": "jest --passWithNoTests"
42
42
  },
43
43
  "dependencies": {
44
- "@proteinjs/db-query": "^1.7.2",
44
+ "@proteinjs/db-query": "^1.8.0",
45
45
  "@proteinjs/logger": "^1.0.21",
46
46
  "@proteinjs/reflection": "^1.2.0",
47
47
  "@proteinjs/serializer": "^1.1.10",
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
68
  },
69
- "gitHead": "521d5d1ca2f86f0ed2bef0309b0dde30506f132b"
69
+ "gitHead": "b25c88ae26fffd1c784b4c7b1174825b52751987"
70
70
  }
package/src/Db.ts CHANGED
@@ -187,7 +187,8 @@ export class Db<R extends Record = Record> implements DbService<R> {
187
187
  await addDefaultFieldValues(table, recordCopy, this.runAsSystem);
188
188
  recordCopy = await this.tableWatcherRunner.runBeforeInsertTableWatchers(table, recordCopy);
189
189
  await this.addColumnInsertHooks(table, recordCopy);
190
- const recordSerializer = new RecordSerializer(table);
190
+ const encryptionContext = await this.encryptionWriteContext(table, recordCopy);
191
+ const recordSerializer = new RecordSerializer(table, encryptionContext);
191
192
  const serializedRecord = await recordSerializer.serialize(recordCopy);
192
193
  const generateInsert = (config: DbDriverDmlStatementConfig) =>
193
194
  new StatementFactory<T>().insert(
@@ -196,6 +197,17 @@ export class Db<R extends Record = Record> implements DbService<R> {
196
197
  this.statementConfigFactory.getStatementConfig(config)
197
198
  );
198
199
  await this.dbDriver.runDml(generateInsert, this.transactionForDriver());
200
+ if (encryptionContext) {
201
+ // The same write that stored the ciphertext maintains its search tokens (rides the
202
+ // ambient transaction when there is one).
203
+ const { EncryptionTokenMaintenance } = await import('./encryption/EncryptionTokenMaintenance');
204
+ await new EncryptionTokenMaintenance().afterInsert(
205
+ table,
206
+ recordCopy,
207
+ encryptionContext.keyOwner,
208
+ this.newSystemDb()
209
+ );
210
+ }
199
211
  await this.runColumnAfterInsertHooks(table, recordCopy);
200
212
  await this.tableWatcherRunner.runAfterInsertTableWatchers(table, recordCopy as T);
201
213
  return recordCopy as T;
@@ -229,17 +241,22 @@ export class Db<R extends Record = Record> implements DbService<R> {
229
241
  }
230
242
 
231
243
  recordCopy = await this.tableWatcherRunner.runBeforeUpdateTableWatchers(table, recordCopy, qb);
232
- const recordSerializer = new RecordSerializer<T>(table);
233
- const serializedRecord = await recordSerializer.serialize(recordCopy);
234
- delete serializedRecord['id'];
235
- const generateUpdate = (config: DbDriverDmlStatementConfig) =>
236
- new StatementFactory<T>().update(
237
- table.name,
238
- serializedRecord as Partial<T>,
239
- qb,
240
- this.statementConfigFactory.getStatementConfig(config)
241
- );
242
- const recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
244
+ let recordUpdateCount: number;
245
+ if (await this.updateTouchesEncryptedColumns(table, recordCopy)) {
246
+ recordUpdateCount = await this.updateEncrypted(table, recordCopy, qb);
247
+ } else {
248
+ const recordSerializer = new RecordSerializer<T>(table);
249
+ const serializedRecord = await recordSerializer.serialize(recordCopy);
250
+ delete serializedRecord['id'];
251
+ const generateUpdate = (config: DbDriverDmlStatementConfig) =>
252
+ new StatementFactory<T>().update(
253
+ table.name,
254
+ serializedRecord as Partial<T>,
255
+ qb,
256
+ this.statementConfigFactory.getStatementConfig(config)
257
+ );
258
+ recordUpdateCount = await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
259
+ }
243
260
  if (!this.runAsSystem && recordUpdateCount === 0) {
244
261
  const id = this.singleRowIdTarget(record, query);
245
262
  if (id !== undefined) {
@@ -380,6 +397,7 @@ export class Db<R extends Record = Record> implements DbService<R> {
380
397
  const recordDeleteCount = await this.dbDriver.runDml(generateDelete, this.transactionForDriver());
381
398
  await this.runCascadeDeletions(table, recordsToDelete);
382
399
  await this.runColumnReverseCascadeDeletions(table, recordsToDelete);
400
+ await this.deleteEncryptionTokenRows(table, recordsToDeleteIds as string[]);
383
401
  await this.tableWatcherRunner.runAfterDeleteTableWatchers(table, recordDeleteCount, recordsToDelete, qb, deleteQb);
384
402
  return recordDeleteCount;
385
403
  }
@@ -701,6 +719,21 @@ export class Db<R extends Record = Record> implements DbService<R> {
701
719
  await column.options.addToQuery(qb, this.runAsSystem, operation);
702
720
  }
703
721
  }
722
+
723
+ // The column-transform phase of the query model (`QueryBuilder.applyColumnTransforms`):
724
+ // each column's own query contract (`Column.queryTransform` — e.g. an encrypted
725
+ // column's condition/sort translation) is applied by the builder, with this
726
+ // operation's runtime — or rejected loudly at query-build time. Runs on every
727
+ // query/update/delete/count, so callers keep writing exactly what they write today.
728
+ // Imported at call time (module-graph discipline — see Db.init).
729
+ const { TableQueryTransformProvider } = await import('./TableQueryTransformProvider');
730
+ await qb.applyColumnTransforms(
731
+ new TableQueryTransformProvider(this.getTable, {
732
+ runAsSystem: this.runAsSystem,
733
+ query: (verifyTable, verifyQb) => this.query(verifyTable, verifyQb as any),
734
+ systemQuery: (derivedTable, derivedQb) => this.newSystemDb().query(derivedTable, derivedQb as any),
735
+ })
736
+ );
704
737
  }
705
738
 
706
739
  private async addColumnInsertHooks(table: Table<any>, record: any) {
@@ -852,6 +885,98 @@ export class Db<R extends Record = Record> implements DbService<R> {
852
885
  await hook();
853
886
  }
854
887
 
888
+ /**
889
+ * The encryption context of a write, when the payload touches encrypted columns: the key
890
+ * owner (the row's permission-source scope owner) whose data key the values encrypt
891
+ * under. Undefined for writes that touch no encrypted column — the serializer then runs
892
+ * exactly as before.
893
+ */
894
+ private async encryptionWriteContext(table: Table<any>, record: any): Promise<{ keyOwner: string } | undefined> {
895
+ const { EncryptedColumns } = await import('./encryption/EncryptedColumns');
896
+ const encryptedColumns = new EncryptedColumns();
897
+ encryptedColumns.ensureSchema(table);
898
+ if (!encryptedColumns.recordTouchesEncryptedColumns(table, record)) {
899
+ return undefined;
900
+ }
901
+
902
+ const { EncryptionRecordHooks } = await import('./encryption/EncryptionRecordHooks');
903
+ return { keyOwner: await new EncryptionRecordHooks().resolveKeyOwnerForWrite(table, record) };
904
+ }
905
+
906
+ private async updateTouchesEncryptedColumns(table: Table<any>, record: any): Promise<boolean> {
907
+ const { EncryptedColumns } = await import('./encryption/EncryptedColumns');
908
+ const encryptedColumns = new EncryptedColumns();
909
+ encryptedColumns.ensureSchema(table);
910
+ return encryptedColumns.recordTouchesEncryptedColumns(table, record);
911
+ }
912
+
913
+ /**
914
+ * Update path for payloads touching encrypted columns. Ciphertext is keyed per row OWNER
915
+ * (the row's scope owner), so one UPDATE statement cannot serve target rows of different
916
+ * owners: the target set is snapshotted (through the caller's own filtered query — the
917
+ * same query-then-act shape `delete` uses), grouped by owner, and updated one DML per
918
+ * owner group with that owner's ciphertext, companions, and search tokens.
919
+ */
920
+ private async updateEncrypted<T extends R>(
921
+ table: Table<T>,
922
+ recordCopy: Partial<T>,
923
+ qb: QueryBuilder<T>
924
+ ): Promise<number> {
925
+ const targetRows = await this._query(table, qb);
926
+ if (targetRows.length === 0) {
927
+ return 0;
928
+ }
929
+
930
+ const { EncryptionRecordHooks } = await import('./encryption/EncryptionRecordHooks');
931
+ const { EncryptionTokenMaintenance } = await import('./encryption/EncryptionTokenMaintenance');
932
+ const hooks = new EncryptionRecordHooks();
933
+ const idsByOwner = new Map<string, string[]>();
934
+ for (const row of targetRows) {
935
+ const owner = await hooks.resolveKeyOwnerForWrite(table, row);
936
+ const ids = idsByOwner.get(owner) ?? [];
937
+ ids.push(row.id);
938
+ idsByOwner.set(owner, ids);
939
+ }
940
+
941
+ let recordUpdateCount = 0;
942
+ const tokenMaintenance = new EncryptionTokenMaintenance();
943
+ for (const [owner, ids] of Array.from(idsByOwner.entries())) {
944
+ const recordSerializer = new RecordSerializer<T>(table, { keyOwner: owner });
945
+ const serializedRecord = await recordSerializer.serialize(recordCopy);
946
+ delete serializedRecord['id'];
947
+ const groupQb = new QueryBuilderFactory()
948
+ .getQueryBuilder(table)
949
+ .condition({ field: 'id', operator: 'IN', value: ids as T[keyof T][] });
950
+ const generateUpdate = (config: DbDriverDmlStatementConfig) =>
951
+ new StatementFactory<T>().update(
952
+ table.name,
953
+ serializedRecord as Partial<T>,
954
+ groupQb,
955
+ this.statementConfigFactory.getStatementConfig(config)
956
+ );
957
+ recordUpdateCount += await this.dbDriver.runDml(generateUpdate, this.transactionForDriver());
958
+ await tokenMaintenance.afterUpdate(table, ids, recordCopy, owner, this.newSystemDb());
959
+ }
960
+
961
+ return recordUpdateCount;
962
+ }
963
+
964
+ /** Deleted rows take their search-token rows with them (no-op for tables without contains columns). */
965
+ private async deleteEncryptionTokenRows(table: Table<any>, deletedIds: string[]): Promise<void> {
966
+ const { EncryptionTokenMaintenance } = await import('./encryption/EncryptionTokenMaintenance');
967
+ await new EncryptionTokenMaintenance().afterDelete(table, deletedIds, this.newSystemDb());
968
+ }
969
+
970
+ /**
971
+ * A system instance riding THIS instance's driver and transaction context — for framework
972
+ * machinery beside a caller's operation (search-token upkeep, token-table reads): the
973
+ * derived tables are default-deny for callers, and the ops must ride the caller's ambient
974
+ * transaction.
975
+ */
976
+ private newSystemDb(): Db<any> {
977
+ return new Db<any>(this.dbDriver, this.getTable, this.transactionContextFactory, true);
978
+ }
979
+
855
980
  /**
856
981
  * A fresh instance for self-wrapping RMW verbs (`updateArrayMembership`, `updatePreserving`)
857
982
  * in a transaction. `runTransaction` carries the open transaction as INSTANCE state, and these
package/src/Record.ts CHANGED
@@ -47,12 +47,18 @@ export function withRecordColumns<T extends Record>(
47
47
 
48
48
  export type SerializedRecord = { [columnName: string]: any };
49
49
 
50
+ /** Who a row being written belongs to — the data key its encrypted columns encrypt under
51
+ * (see `EncryptionRecordHooks`). Resolved by `Db` for writes touching encrypted columns. */
52
+ export type RecordEncryptionContext = { keyOwner: string };
53
+
50
54
  export class RecordSerializer<T extends Record> {
51
55
  private logger = new Logger({ name: this.constructor.name });
52
56
  private table: Table<T>;
57
+ private encryptionContext?: RecordEncryptionContext;
53
58
 
54
- constructor(table: Table<T>) {
59
+ constructor(table: Table<T>, encryptionContext?: RecordEncryptionContext) {
55
60
  this.table = table;
61
+ this.encryptionContext = encryptionContext;
56
62
  }
57
63
 
58
64
  async serialize(record: any): Promise<SerializedRecord> {
@@ -82,20 +88,31 @@ export class RecordSerializer<T extends Record> {
82
88
  this.logger.warn({ message: `Fields were omitted during serialization`, obj: { omittedFields } });
83
89
  }
84
90
 
91
+ // The transparent encryption seam (`ColumnOptions.encrypted`): encrypted columns'
92
+ // serialized values become ciphertext envelopes, search/sort companions are derived
93
+ // beside them. Imported at call time — this module sits below the encryption machinery
94
+ // in the package's module graph.
95
+ const { EncryptionRecordHooks } = await import('./encryption/EncryptionRecordHooks');
96
+ await new EncryptionRecordHooks().onSerialize(this.table, serialized, this.encryptionContext);
85
97
  return serialized;
86
98
  }
87
99
 
88
100
  async deserialize(serializedRecord: SerializedRecord): Promise<T> {
101
+ // The transparent decryption seam: ciphertext envelopes decrypt (self-describing — the
102
+ // envelope names its key), framework companion columns drop out of the result.
103
+ const { EncryptionRecordHooks } = await import('./encryption/EncryptionRecordHooks');
104
+ const prepared = await new EncryptionRecordHooks().onDeserialize(this.table, serializedRecord);
105
+
89
106
  const deserialized: any = {};
90
107
  const fieldSerializer = new FieldSerializer(this.table);
91
108
  const omittedFields: string[] = [];
92
- for (const columnName in serializedRecord) {
93
- const serializedFieldValue = serializedRecord[columnName];
109
+ for (const columnName in prepared) {
110
+ const serializedFieldValue = prepared[columnName];
94
111
  try {
95
112
  const { fieldPropertyName, fieldValue } = await fieldSerializer.deserialize(
96
113
  columnName,
97
114
  serializedFieldValue,
98
- serializedRecord
115
+ prepared
99
116
  );
100
117
  deserialized[fieldPropertyName] = fieldValue;
101
118
  } catch (MissingFieldError) {
package/src/Table.ts CHANGED
@@ -2,9 +2,10 @@ import { Loadable, SourceRepository } from '@proteinjs/reflection';
2
2
  import { CustomSerializableObject } from '@proteinjs/serializer';
3
3
  import { isRecordColumn, Record } from './Record';
4
4
  import { TableSerializerId } from './serializers/TableSerializer';
5
- import { QueryBuilder } from '@proteinjs/db-query';
5
+ import { ColumnQueryTransform, QueryBuilder } from '@proteinjs/db-query';
6
6
  import { Identity, TableOperationsAuth } from './auth/TableAuth';
7
7
  import { Db } from './Db';
8
+ import { EncryptionDerivedTableRegistry } from './encryption/EncryptionDerivedTableRegistry';
8
9
 
9
10
  export const isTable = (obj: any) => obj.__serializerId === TableSerializerId;
10
11
 
@@ -14,13 +15,38 @@ export const tableByName = (name: string) => {
14
15
  const tables = getTables();
15
16
  for (const table of tables) {
16
17
  if (table.name == name) {
17
- return table;
18
+ return ensureEncryptionSchema(table);
18
19
  }
19
20
  }
20
21
 
22
+ // Framework-DERIVED tables (search-token tables beside encrypted columns) are synthesized
23
+ // from column config rather than declared in source, so the reflection registry never
24
+ // sees them (see EncryptionDerivedTableRegistry — a runtime-cycle-free, type-only import).
25
+ const derivedTable = EncryptionDerivedTableRegistry.get(name);
26
+ if (derivedTable) {
27
+ return derivedTable;
28
+ }
29
+
21
30
  throw new Error(`Unable to find table: ${name}`);
22
31
  };
23
32
 
33
+ /**
34
+ * Table instances are constructed ad hoc all over consumer code, and the encryption-derived
35
+ * physical schema (companion columns — see `EncryptedColumns.ensureSchema`) is injected
36
+ * per-instance at the seams that use an instance. Name-based resolution is one of those
37
+ * seams: statement generation and driver column-type lookups resolve the REGISTRY's
38
+ * instance through here, which must carry the same derived columns as the instance the
39
+ * caller handed the Db. Idempotent (marker-checked); a call-time require keeps the module
40
+ * graph acyclic at load time.
41
+ */
42
+ const ensureEncryptionSchema = (table: Table<any>): Table<any> => {
43
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
44
+ const { EncryptedColumns } =
45
+ require('./encryption/EncryptedColumns') as typeof import('./encryption/EncryptedColumns');
46
+ new EncryptedColumns().ensureSchema(table);
47
+ return table;
48
+ };
49
+
24
50
  export const getColumnPropertyName = (table: Table<any>, columnName: string) => {
25
51
  for (const columnPropertyName in table.columns) {
26
52
  const column = table.columns[columnPropertyName];
@@ -126,6 +152,20 @@ export type Columns<T> = {
126
152
  [P in OptionalProps<T>]?: Column<T[P] | undefined, any>;
127
153
  };
128
154
 
155
+ /**
156
+ * What a column's query transform runs WITH: the current operation's authority and its
157
+ * driver/transaction-riding query runners. Supplied by `Db` per operation
158
+ * (`Db.addColumnQueries`), so a transform's own reads behave like part of the query that
159
+ * carried them.
160
+ */
161
+ export interface ColumnQueryRuntime {
162
+ runAsSystem: boolean;
163
+ /** Query with the CALLER's authority (auth + column queries apply). */
164
+ query: (table: Table<any>, qb: QueryBuilder<any>) => Promise<any[]>;
165
+ /** System-authority query — for framework-derived tables that are default-deny to callers. */
166
+ systemQuery: (table: Table<any>, qb: QueryBuilder<any>) => Promise<any[]>;
167
+ }
168
+
129
169
  export type Column<T, Serialized> = {
130
170
  name: string;
131
171
  /**
@@ -137,6 +177,21 @@ export type Column<T, Serialized> = {
137
177
  options?: ColumnOptions;
138
178
  serialize?: (fieldValue: T | null | undefined) => Promise<Serialized | null | undefined>;
139
179
  deserialize?: (serializedFieldValue: Serialized | null, serializedRecord: any) => Promise<T | null | void>;
180
+ /**
181
+ * The column's QUERY-side contract, paralleling `serialize`/`deserialize` on the storage
182
+ * side: how uses of this column in a query (conditions, ORDER BY, aggregation, GROUP BY)
183
+ * translate into uses of what the database actually stores and indexes for it — see
184
+ * `ColumnQueryTransform` (@proteinjs/db-query). Applied by
185
+ * `QueryBuilder.applyColumnTransforms` on every query, through
186
+ * `TableQueryTransformProvider`.
187
+ *
188
+ * A factory rather than a bare transform: translation may consult derived index state
189
+ * (e.g. an encrypted column's token table), so a transform is built per operation with
190
+ * the operation's `ColumnQueryRuntime` — its driver-riding query runners and authority.
191
+ * Derived by the framework for encrypted columns (`EncryptedColumns.ensureSchema`);
192
+ * any column may supply one.
193
+ */
194
+ queryTransform?: (runtime: ColumnQueryRuntime) => ColumnQueryTransform;
140
195
  beforeDelete?: (
141
196
  table: Table<any>,
142
197
  columnPropertyName: string,
@@ -146,8 +201,60 @@ export type Column<T, Serialized> = {
146
201
  ) => Promise<void>;
147
202
  };
148
203
 
204
+ /**
205
+ * Capabilities of an encrypted column. Nested INSIDE the `encrypted` declaration on purpose:
206
+ * `searchable` and `sortKey` only mean anything on an encrypted column (a plaintext column
207
+ * is inherently searchable and sortable through normal SQL), so the invalid state — a
208
+ * search/sort capability declared without encryption — is unrepresentable.
209
+ */
210
+ export type EncryptedColumnConfig = {
211
+ /**
212
+ * Index the value for native querying over ciphertext (derived automatically at the
213
+ * database layer — see `EncryptedColumns`):
214
+ * - `'contains'` — word + trigram search tokens, keyed-fingerprinted per owner, serving
215
+ * the LIKE contains/prefix family with exact results (candidate cover + decrypt-verify).
216
+ * - `'equality'` — one whole-value fingerprint companion column serving `=` / `IN` /
217
+ * get-by-value as a single indexed lookup (and value uniqueness, per owner).
218
+ * Encrypted columns that are never queried by value declare neither and carry no
219
+ * derivatives — the default.
220
+ */
221
+ searchable?: 'contains' | 'equality';
222
+ /**
223
+ * Native ORDER BY at any scale through a DECLARED bounded reveal: an ordered
224
+ * representation of the value's first `revealPrefix` characters (normalized) is stored
225
+ * beside the ciphertext and the database sorts on it. The leak, stated plainly: raw
226
+ * database access can see each value's first `revealPrefix` characters — nothing else.
227
+ * A conscious, schema-author-declared tradeoff; never the default. Rows sharing a prefix
228
+ * tie-break app-side within the returned page.
229
+ */
230
+ sortKey?: { revealPrefix: number };
231
+ };
232
+
149
233
  export type ColumnOptions = {
150
234
  unique?: { unique: boolean; indexName?: string };
235
+ /**
236
+ * Whether this column's values are stored encrypted (AES-256-GCM under per-owner data
237
+ * keys wrapped by the deployment's master key — see `DbEncryptionConfig`). The value is
238
+ * either `false` (plaintext — said out loud) or a config object:
239
+ *
240
+ * ```ts
241
+ * encrypted: false // plaintext (explicit)
242
+ * encrypted: {} // encrypted, never queried by value
243
+ * encrypted: { searchable: 'contains' } // + indexed contains/prefix search
244
+ * encrypted: { searchable: 'equality' } // + indexed exact-match lookup
245
+ * encrypted: { sortKey: { revealPrefix: 3 } } // + native ORDER BY via a declared bounded reveal
246
+ * ```
247
+ *
248
+ * Everything downstream — the transparent encrypt/decrypt seam, companion index
249
+ * derivation, query translation, loud contract rejections, lifecycle backfills — is
250
+ * derived automatically at the database layer; callers never write an encrypt or decrypt
251
+ * call. Query shapes outside the contract are rejected at query-build time
252
+ * (`EncryptionQueryTranslator`).
253
+ *
254
+ * When `DbEncryptionConfig.requireEncryptedDeclarations` is on, every text-holding
255
+ * column MUST carry this declaration — registration fails loudly otherwise.
256
+ */
257
+ encrypted?: false | EncryptedColumnConfig;
151
258
  /**
152
259
  * The column in the reference table `table` is the primary key of the table (`id` unless otherwise specified in the Table definition)
153
260
  *
@@ -0,0 +1,31 @@
1
+ import { ColumnQueryTransform, ColumnQueryTransformProvider } from '@proteinjs/db-query';
2
+ import { Column, ColumnQueryRuntime, Table } from './Table';
3
+
4
+ /**
5
+ * The Table-model side of db-query's column query-transform seam: resolves a
6
+ * (tableName, columnProperty) pair to the column's own `Column.queryTransform`, built with
7
+ * the current operation's `ColumnQueryRuntime`. `Db.addColumnQueries` hands one of these
8
+ * to `QueryBuilder.applyColumnTransforms` on every query, so a column's query contract is
9
+ * consulted wherever the column is used — including inside subquery values on other
10
+ * tables, resolved through the same table registry the statement layer uses.
11
+ */
12
+ export class TableQueryTransformProvider implements ColumnQueryTransformProvider {
13
+ constructor(
14
+ private getTable: (tableName: string) => Table<any>,
15
+ private runtime: ColumnQueryRuntime
16
+ ) {}
17
+
18
+ getTransform(tableName: string, columnPropertyName: string): ColumnQueryTransform | undefined {
19
+ let table: Table<any>;
20
+ try {
21
+ table = this.getTable(tableName);
22
+ } catch {
23
+ // Not a registered table (e.g. an aggregate '*' field, or a name outside the model) —
24
+ // nothing to consult.
25
+ return undefined;
26
+ }
27
+
28
+ const column = (table.columns as any)[columnPropertyName] as Column<any, any> | undefined;
29
+ return column?.queryTransform?.(this.runtime);
30
+ }
31
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Base64url (RFC 4648 §5) over Buffers, hand-rolled because the package's @types/node
3
+ * predates the 'base64url' BufferEncoding. URL/filename-safe alphabet, no padding — safe
4
+ * inside the colon-delimited ciphertext envelope and as index-column values.
5
+ */
6
+ export class Base64Url {
7
+ static encode(bytes: Buffer): string {
8
+ return bytes.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
9
+ }
10
+
11
+ static decode(encoded: string): Buffer {
12
+ return Buffer.from(encoded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
13
+ }
14
+ }
@@ -0,0 +1,40 @@
1
+ import { Table } from '../Table';
2
+ import { Record, withRecordColumns } from '../Record';
3
+ import { IntegerColumn, StringColumn } from '../Columns';
4
+
5
+ /**
6
+ * One wrapped per-owner data key version (see `DataKeyStore`).
7
+ *
8
+ * - `active` — encrypts new writes (the highest active version) and fingerprints queries.
9
+ * - `retired` — decrypt-only: envelopes referencing it still unwrap, but no new writes or
10
+ * query fingerprints use it. A rotation walk moves every row off a version, then retires it.
11
+ *
12
+ * Crypto-shred = deleting an owner's rows here: every envelope naming that owner becomes
13
+ * permanently unreadable, including inside backups.
14
+ */
15
+ export interface DataEncryptionKey extends Record {
16
+ /** The key owner (a user id — the row's permission-source scope owner). */
17
+ owner: string;
18
+ /** Monotonic per-owner version; envelopes name (owner, version). */
19
+ version: number;
20
+ /** The data-key material, wrapped by the master key (`MasterKeyProvider`). */
21
+ wrappedKey: string;
22
+ status: 'active' | 'retired';
23
+ }
24
+
25
+ export class DataEncryptionKeyTable extends Table<DataEncryptionKey> {
26
+ public name = 'data_encryption_key';
27
+ public columns: Table<DataEncryptionKey>['columns'] = withRecordColumns<DataEncryptionKey>({
28
+ owner: new StringColumn('owner', { nullable: false }, 36),
29
+ version: new IntegerColumn('version', { nullable: false }),
30
+ wrappedKey: new StringColumn('wrapped_key', { nullable: false }, 'MAX'),
31
+ status: new StringColumn('status', { nullable: false }, 16),
32
+ });
33
+ /** (owner, version) is the identity an envelope names — the race loser on concurrent
34
+ * first-write key creation fails here and re-reads the winner's key. */
35
+ public indexes: { columns: (keyof DataEncryptionKey)[]; name?: string; unique?: boolean }[] = [
36
+ { columns: ['owner', 'version'], name: 'data_encryption_key_owner_version_unique', unique: true },
37
+ ];
38
+ // No auth block: default-deny for every non-system caller. Key rows are framework
39
+ // machinery, only ever touched through system paths (DataKeyStore).
40
+ }