Skip to content

Commit b07a57f

Browse files
authored
Merge pull request #296 from cipherstash/usability-1
fix(stack): PR_BYPASS usability issues and bun env support
2 parents 5aa6c46 + 166d82b commit b07a57f

File tree

11 files changed

+196
-109
lines changed

11 files changed

+196
-109
lines changed

docs/reference/model-operations.md

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -113,60 +113,66 @@ const decryptedUsers = decryptedResult.data;
113113

114114
## Type safety
115115

116-
### Using type parameters
116+
### Schema-aware return types (recommended)
117117

118-
`@cipherstash/stack` provides strong TypeScript support through generic type parameters:
118+
`encryptModel` and `bulkEncryptModels` return **schema-aware types** when you let TypeScript infer the type parameters from the arguments.
119+
Fields matching the table schema are typed as `Encrypted`, while other fields retain their original types:
119120

120121
```typescript
121-
// Define your model type
122+
import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema";
123+
122124
type User = {
123125
id: string;
124-
email: string | null;
125-
address: string | null;
126+
email: string;
127+
address: string;
126128
createdAt: Date;
127-
metadata?: {
128-
preferences?: {
129-
notifications: boolean;
130-
theme: string;
131-
};
132-
};
133129
};
134130

135-
// Use the type parameter for type safety
136-
const encryptedResult = await client.encryptModel<User>(user, users);
137-
const decryptedResult = await client.decryptModel<User>(encryptedUser);
131+
const users = encryptedTable("users", {
132+
email: encryptedColumn("email").freeTextSearch(),
133+
address: encryptedColumn("address"),
134+
});
135+
136+
// Let TypeScript infer the return type from the schema
137+
const encryptedResult = await client.encryptModel(user, users);
138+
139+
// encryptedResult.data.email -> Encrypted (schema field)
140+
// encryptedResult.data.address -> Encrypted (schema field)
141+
// encryptedResult.data.id -> string (not in schema)
142+
// encryptedResult.data.createdAt -> Date (not in schema)
143+
144+
// Decryption works the same way
145+
const decryptedResult = await client.decryptModel(encryptedResult.data);
138146

139147
// Bulk operations
140-
const bulkEncryptedResult = await client.bulkEncryptModels<User>(
141-
userModels,
142-
users
148+
const bulkEncryptedResult = await client.bulkEncryptModels(userModels, users);
149+
const bulkDecryptedResult = await client.bulkDecryptModels(
150+
bulkEncryptedResult.data
143151
);
144-
const bulkDecryptedResult =
145-
await client.bulkDecryptModels<User>(encryptedUsers);
146152
```
147153

148154
The type system ensures:
149155

150-
- Type safety for input models
156+
- Schema-defined fields are typed as `Encrypted` in the return value
157+
- Non-schema fields retain their original types
151158
- Correct handling of optional and nullable fields
152159
- Preservation of nested object structures
153-
- Type safety for encrypted and decrypted results
154160

155-
### Type inference from schema
161+
### Using explicit type parameters
156162

157-
The model operations can infer types from your schema definition:
163+
You can still pass an explicit type parameter for backward compatibility. When you do, the schema type parameter defaults to the widened `ProtectTableColumn`, and the return type degrades gracefully to your provided type (same behavior as before):
158164

159165
```typescript
160-
const users = encryptedTable("users", {
161-
email: encryptedColumn("email").freeTextSearch(),
162-
address: encryptedColumn("address"),
163-
});
166+
// Explicit type parameter — return type is User (no schema-aware mapping)
167+
const result = await client.encryptModel<User>(user, users);
164168

165-
// Types are inferred from the schema
166-
const result = await client.encryptModel(user, users);
167-
// Result type includes encrypted fields for email and address
169+
// For full schema-aware types with explicit parameters, provide both:
170+
const result = await client.encryptModel<User, typeof users>(user, users);
168171
```
169172

173+
> [!TIP]
174+
> For the best developer experience, omit the type parameter and let TypeScript infer the schema-aware return type from the `table` argument.
175+
170176
## Identity-aware model operations
171177

172178
All model operations support lock contexts for identity-aware encryption:

packages/stack/README.md

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -139,29 +139,27 @@ const decrypted = await client.decrypt(encrypted.data)
139139

140140
Encrypt or decrypt an entire object. Only fields matching your schema are encrypted; other fields pass through unchanged.
141141

142+
The return type is **schema-aware**: fields matching the table schema are typed as `Encrypted`, while other fields retain their original types. For best results, let TypeScript infer the type parameters from the arguments:
143+
142144
```typescript
145+
type User = { id: string; email: string; createdAt: Date }
146+
143147
const user = {
144148
id: "user_123",
145149
email: "alice@example.com", // defined in schema -> encrypted
146150
createdAt: new Date(), // not in schema -> unchanged
147151
}
148152

149-
// Encrypt a model
153+
// Let TypeScript infer the return type from the schema
150154
const encryptedResult = await client.encryptModel(user, users)
155+
// encryptedResult.data.email -> Encrypted
156+
// encryptedResult.data.id -> string
157+
// encryptedResult.data.createdAt -> Date
151158

152159
// Decrypt a model
153160
const decryptedResult = await client.decryptModel(encryptedResult.data)
154161
```
155162

156-
Type-safe generics are supported:
157-
158-
```typescript
159-
type User = { id: string; email: string; createdAt: Date }
160-
161-
const result = await client.encryptModel<User>(user, users)
162-
const back = await client.decryptModel<User>(result.data)
163-
```
164-
165163
### Bulk Operations
166164

167165
All bulk methods make a single call to ZeroKMS regardless of the number of records, while still using a unique key per value.
@@ -592,11 +590,11 @@ function Encryption(config: EncryptionClientConfig): Promise<EncryptionClient>
592590
| `decrypt` | `(encryptedData)` | `DecryptOperation` (thenable) |
593591
| `encryptQuery` | `(plaintext, { column, table, queryType?, returnType? })` | `EncryptQueryOperation` (thenable) |
594592
| `encryptQuery` | `(terms: ScalarQueryTerm[])` | `BatchEncryptQueryOperation` (thenable) |
595-
| `encryptModel` | `(model, table)` | `EncryptModelOperation<T>` (thenable) |
593+
| `encryptModel` | `(model, table)` | `EncryptModelOperation<EncryptedFromSchema<T, S>>` (thenable) |
596594
| `decryptModel` | `(encryptedModel)` | `DecryptModelOperation<T>` (thenable) |
597595
| `bulkEncrypt` | `(plaintexts, { column, table })` | `BulkEncryptOperation` (thenable) |
598596
| `bulkDecrypt` | `(encryptedPayloads)` | `BulkDecryptOperation` (thenable) |
599-
| `bulkEncryptModels` | `(models, table)` | `BulkEncryptModelsOperation<T>` (thenable) |
597+
| `bulkEncryptModels` | `(models, table)` | `BulkEncryptModelsOperation<EncryptedFromSchema<T, S>>` (thenable) |
600598
| `bulkDecryptModels` | `(encryptedModels)` | `BulkDecryptModelsOperation<T>` (thenable) |
601599

602600
All operations are thenable (awaitable) and support `.withLockContext(lockContext)` for identity-aware encryption.

packages/stack/__tests__/types.test-d.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
DecryptedFields,
1414
Encrypted,
1515
EncryptedFields,
16+
EncryptedFromSchema,
1617
EncryptedReturnType,
1718
KeysetIdentifier,
1819
OtherFields,
@@ -110,4 +111,35 @@ describe('Type inference', () => {
110111
type Enc = InferEncrypted<typeof table>
111112
expectTypeOf<Enc>().toMatchTypeOf<{ email: Encrypted }>()
112113
})
114+
115+
it('EncryptedFromSchema maps schema fields to Encrypted, leaves others unchanged', () => {
116+
type User = { id: string; email: string; createdAt: Date }
117+
type Schema = { email: ProtectColumn }
118+
type Result = EncryptedFromSchema<User, Schema>
119+
expectTypeOf<Result>().toEqualTypeOf<{
120+
id: string
121+
email: Encrypted
122+
createdAt: Date
123+
}>()
124+
})
125+
126+
it('EncryptedFromSchema with widened ProtectTableColumn degrades to T', () => {
127+
type User = { id: string; email: string }
128+
type Result = EncryptedFromSchema<User, ProtectTableColumn>
129+
// When S is the wide ProtectTableColumn, S[K] is the full union, not ProtectColumn alone.
130+
// The conditional [S[K]] extends [ProtectColumn | ProtectValue] fails, so fields stay as-is.
131+
expectTypeOf<Result>().toEqualTypeOf<{ id: string; email: string }>()
132+
})
133+
134+
it('Decrypted reverses EncryptedFromSchema correctly', () => {
135+
type User = { id: string; email: string; createdAt: Date }
136+
type Schema = { email: ProtectColumn }
137+
type EncryptedUser = EncryptedFromSchema<User, Schema>
138+
type DecryptedUser = Decrypted<EncryptedUser>
139+
expectTypeOf<DecryptedUser>().toMatchTypeOf<{
140+
id: string
141+
email: string
142+
createdAt: Date
143+
}>()
144+
})
113145
})

packages/stack/src/encryption/ffi/index.ts

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import type {
99
BulkDecryptPayload,
1010
BulkEncryptPayload,
1111
Client,
12-
Decrypted,
1312
EncryptOptions,
1413
EncryptQueryOptions,
1514
Encrypted,
15+
EncryptedFromSchema,
1616
KeysetIdentifier,
1717
ScalarQueryTerm,
1818
} from '@/types'
@@ -83,10 +83,10 @@ export class EncryptionClient {
8383
this.client = await newClient({
8484
encryptConfig: validated,
8585
clientOpts: {
86-
workspaceCrn: config.workspaceCrn,
87-
accessKey: config.accessKey,
88-
clientId: config.clientId,
89-
clientKey: config.clientKey,
86+
workspaceCrn: config.workspaceCrn ?? process.env.CS_WORKSPACE_CRN,
87+
accessKey: config.accessKey ?? process.env.CS_CLIENT_ACCESS_KEY,
88+
clientId: config.clientId ?? process.env.CS_CLIENT_ID,
89+
clientKey: config.clientKey ?? process.env.CS_CLIENT_KEY,
9090
keyset: toFfiKeysetIdentifier(config.keyset),
9191
},
9292
})
@@ -324,10 +324,16 @@ export class EncryptionClient {
324324
* All other fields are passed through unchanged. Returns a thenable operation
325325
* that supports `.withLockContext()` for identity-aware encryption.
326326
*
327+
* The return type is **schema-aware**: fields matching the table schema are
328+
* typed as `Encrypted`, while other fields retain their original types. For
329+
* best results, let TypeScript infer the type parameters from the arguments
330+
* rather than providing an explicit type argument.
331+
*
327332
* @param input - The model object with plaintext values to encrypt.
328333
* @param table - The table schema defining which fields to encrypt.
329-
* @returns An `EncryptModelOperation<T>` that can be awaited to get a `Result`
330-
* containing the model with encrypted fields, or an `EncryptionError`.
334+
* @returns An `EncryptModelOperation` that can be awaited to get a `Result`
335+
* containing the model with schema-defined fields typed as `Encrypted`,
336+
* or an `EncryptionError`.
331337
*
332338
* @example
333339
* ```typescript
@@ -342,24 +348,26 @@ export class EncryptionClient {
342348
*
343349
* const client = await Encryption({ schemas: [usersSchema] })
344350
*
345-
* const result = await client.encryptModel<User>(
351+
* // Let TypeScript infer the return type from the schema.
352+
* // result.data.email is typed as `Encrypted`, result.data.id stays `string`.
353+
* const result = await client.encryptModel(
346354
* { id: "user_123", email: "alice@example.com", createdAt: new Date() },
347355
* usersSchema,
348356
* )
349357
*
350358
* if (result.failure) {
351359
* console.error(result.failure.message)
352360
* } else {
353-
* // result.data.id is unchanged, result.data.email is encrypted
354-
* console.log(result.data)
361+
* console.log(result.data.id) // string
362+
* console.log(result.data.email) // Encrypted
355363
* }
356364
* ```
357365
*/
358-
encryptModel<T extends Record<string, unknown>>(
359-
input: Decrypted<T>,
360-
table: ProtectTable<ProtectTableColumn>,
361-
): EncryptModelOperation<T> {
362-
return new EncryptModelOperation(this.client, input, table)
366+
encryptModel<T extends Record<string, unknown>, S extends ProtectTableColumn = ProtectTableColumn>(
367+
input: T,
368+
table: ProtectTable<S>,
369+
): EncryptModelOperation<EncryptedFromSchema<T, S>> {
370+
return new EncryptModelOperation(this.client, input as Record<string, unknown>, table)
363371
}
364372

365373
/**
@@ -403,10 +411,15 @@ export class EncryptionClient {
403411
* while still using a unique key for each encrypted value. Only fields
404412
* matching the table schema are encrypted; other fields pass through unchanged.
405413
*
414+
* The return type is **schema-aware**: fields matching the table schema are
415+
* typed as `Encrypted`, while other fields retain their original types. For
416+
* best results, let TypeScript infer the type parameters from the arguments.
417+
*
406418
* @param input - An array of model objects with plaintext values to encrypt.
407419
* @param table - The table schema defining which fields to encrypt.
408-
* @returns A `BulkEncryptModelsOperation<T>` that can be awaited to get a `Result`
409-
* containing an array of models with encrypted fields, or an `EncryptionError`.
420+
* @returns A `BulkEncryptModelsOperation` that can be awaited to get a `Result`
421+
* containing an array of models with schema-defined fields typed as `Encrypted`,
422+
* or an `EncryptionError`.
410423
*
411424
* @example
412425
* ```typescript
@@ -421,7 +434,9 @@ export class EncryptionClient {
421434
*
422435
* const client = await Encryption({ schemas: [usersSchema] })
423436
*
424-
* const result = await client.bulkEncryptModels<User>(
437+
* // Let TypeScript infer the return type from the schema.
438+
* // Each item's email is typed as `Encrypted`, id stays `string`.
439+
* const result = await client.bulkEncryptModels(
425440
* [
426441
* { id: "1", email: "alice@example.com" },
427442
* { id: "2", email: "bob@example.com" },
@@ -434,11 +449,11 @@ export class EncryptionClient {
434449
* }
435450
* ```
436451
*/
437-
bulkEncryptModels<T extends Record<string, unknown>>(
438-
input: Array<Decrypted<T>>,
439-
table: ProtectTable<ProtectTableColumn>,
440-
): BulkEncryptModelsOperation<T> {
441-
return new BulkEncryptModelsOperation(this.client, input, table)
452+
bulkEncryptModels<T extends Record<string, unknown>, S extends ProtectTableColumn = ProtectTableColumn>(
453+
input: Array<T>,
454+
table: ProtectTable<S>,
455+
): BulkEncryptModelsOperation<EncryptedFromSchema<T, S>> {
456+
return new BulkEncryptModelsOperation(this.client, input as Array<Record<string, unknown>>, table)
442457
}
443458

444459
/**

0 commit comments

Comments
 (0)