@maestro-js/agent-skills 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/choosing-an-npm-package/SKILL.md +72 -0
- package/dist/maestro-cli/SKILL.md +257 -0
- package/dist/maestro-packages/SKILL.md +42 -0
- package/dist/maestro-packages/broadcast.md +306 -0
- package/dist/maestro-packages/cache-control.md +337 -0
- package/dist/maestro-packages/cache.md +397 -0
- package/dist/maestro-packages/config.md +203 -0
- package/dist/maestro-packages/context.md +365 -0
- package/dist/maestro-packages/crypt.md +253 -0
- package/dist/maestro-packages/db.md +391 -0
- package/dist/maestro-packages/events.md +295 -0
- package/dist/maestro-packages/exceptions.md +231 -0
- package/dist/maestro-packages/form.md +391 -0
- package/dist/maestro-packages/hash.md +248 -0
- package/dist/maestro-packages/helpers.md +249 -0
- package/dist/maestro-packages/log.md +321 -0
- package/dist/maestro-packages/mail.md +365 -0
- package/dist/maestro-packages/metrics.md +236 -0
- package/dist/maestro-packages/queue.md +284 -0
- package/dist/maestro-packages/rate-limiting.md +206 -0
- package/dist/maestro-packages/react-router-file-routes.md +337 -0
- package/dist/maestro-packages/recurring-jobs.md +353 -0
- package/dist/maestro-packages/sql.md +316 -0
- package/dist/maestro-packages/tracing.md +240 -0
- package/package.json +33 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: context
|
|
3
|
+
description: "Guide for working with @maestro-js/context, a request-scoped key-value storage package backed by Node.js AsyncLocalStorage. Use when working with @maestro-js/context, adding per-request state, storing visible or hidden context values, propagating context across async boundaries, integrating context with logging or tracing, creating typed context item handles, or transferring context stores between execution scopes. Covers the Provider pattern, visible/hidden buckets, run/runWith lifecycle, createItem typed handles, getStore for context propagation, and testing patterns."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @maestro-js/context
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
The `@maestro-js/context` package provides request-scoped key-value storage backed by Node.js `AsyncLocalStorage`. Each call to `run()` creates an isolated store with two buckets -- visible (safe for logging) and hidden (for sensitive data like tokens) -- ensuring data never leaks between concurrent requests.
|
|
11
|
+
|
|
12
|
+
## Quick Setup
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { Context } from '@maestro-js/context'
|
|
16
|
+
|
|
17
|
+
// Create and register a context provider
|
|
18
|
+
const ctx = Context.Provider.create()
|
|
19
|
+
Context.Provider.register('default', ctx)
|
|
20
|
+
|
|
21
|
+
// Use the default facade directly
|
|
22
|
+
Context.run(() => {
|
|
23
|
+
Context.add('userId', 123)
|
|
24
|
+
Context.get<number>('userId') // 123
|
|
25
|
+
Context.all() // { userId: 123 }
|
|
26
|
+
})
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## API Reference
|
|
30
|
+
|
|
31
|
+
### Lifecycle Methods
|
|
32
|
+
|
|
33
|
+
#### `run<T>(callback: () => T): T`
|
|
34
|
+
|
|
35
|
+
Initialize a fresh context store for the duration of `callback`. All `add`/`get`/`push` calls inside the callback (including across `await` boundaries) share the same isolated store. Returns the callback's return value.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const result = Context.run(() => {
|
|
39
|
+
Context.add('requestId', 'abc-123')
|
|
40
|
+
return Context.get('requestId')
|
|
41
|
+
})
|
|
42
|
+
// result === 'abc-123'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
#### `runWith<T>(store: Context.Store | undefined, callback: () => T): T`
|
|
46
|
+
|
|
47
|
+
Run `callback` within a previously captured store so context is available inside. If `store` is `undefined`, run the callback without any context (all reads return `undefined`).
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
Context.run(() => {
|
|
51
|
+
Context.add('userId', 42)
|
|
52
|
+
const store = Context.getStore()
|
|
53
|
+
|
|
54
|
+
// Later, in a different async boundary:
|
|
55
|
+
Context.runWith(store, () => {
|
|
56
|
+
Context.get('userId') // 42
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
#### `getStore(): Context.Store | undefined`
|
|
62
|
+
|
|
63
|
+
Return a reference to the current `AsyncLocalStorage` store, or `undefined` if called outside a `run()`. The returned store is a live reference -- mutations via `runWith` affect the original.
|
|
64
|
+
|
|
65
|
+
### Visible Context Methods
|
|
66
|
+
|
|
67
|
+
#### `add(key: Context.Key, value: any): void`
|
|
68
|
+
|
|
69
|
+
Store a key-value pair in the visible context, overriding any existing value.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
Context.run(() => {
|
|
73
|
+
Context.add('tenantId', 'acme-corp')
|
|
74
|
+
Context.add('tenantId', 'other-corp') // overrides
|
|
75
|
+
Context.get('tenantId') // 'other-corp'
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
#### `get<Value = unknown>(key: Context.Key): Value | undefined`
|
|
80
|
+
|
|
81
|
+
Retrieve the value for a key from the visible context, or `undefined` if absent.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
Context.run(() => {
|
|
85
|
+
Context.add('role', 'admin')
|
|
86
|
+
Context.get<string>('role') // 'admin'
|
|
87
|
+
Context.get('nonexistent') // undefined
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
#### `has(key: Context.Key): boolean`
|
|
92
|
+
|
|
93
|
+
Return `true` if the key exists in the visible context.
|
|
94
|
+
|
|
95
|
+
#### `del(...key: Context.Key[]): void`
|
|
96
|
+
|
|
97
|
+
Remove one or more keys from the visible context.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
Context.run(() => {
|
|
101
|
+
Context.add('a', 1)
|
|
102
|
+
Context.add('b', 2)
|
|
103
|
+
Context.del('a', 'b')
|
|
104
|
+
Context.has('a') // false
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
#### `push(key: Context.Key, ...value: any[]): void`
|
|
109
|
+
|
|
110
|
+
Append value(s) to an array in the visible context. Create the array if the key does not exist. Wrap an existing non-array value into an array before appending.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
Context.run(() => {
|
|
114
|
+
Context.push('tags', 'admin')
|
|
115
|
+
Context.get('tags') // ['admin']
|
|
116
|
+
|
|
117
|
+
Context.push('tags', 'user', 'verified')
|
|
118
|
+
Context.get('tags') // ['admin', 'user', 'verified']
|
|
119
|
+
|
|
120
|
+
Context.add('scalar', 'x')
|
|
121
|
+
Context.push('scalar', 'y')
|
|
122
|
+
Context.get('scalar') // ['x', 'y']
|
|
123
|
+
})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### `all(): { [key: Context.Key]: unknown }`
|
|
127
|
+
|
|
128
|
+
Return all visible context entries as a plain object. Hidden entries are excluded.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
Context.run(() => {
|
|
132
|
+
Context.add('userId', 123)
|
|
133
|
+
Context.addHidden('authToken', 'secret')
|
|
134
|
+
Context.all() // { userId: 123 }
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
#### `createItem<Value = unknown>(key: Context.Key)`
|
|
139
|
+
|
|
140
|
+
Return a typed handle scoped to a single key in the visible context. The handle exposes `add(value)`, `get()`, `del()`, and `push(...)` methods.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
Context.run(() => {
|
|
144
|
+
const userId = Context.createItem<number>('userId')
|
|
145
|
+
userId.add(42)
|
|
146
|
+
userId.get() // 42
|
|
147
|
+
userId.del()
|
|
148
|
+
userId.get() // undefined
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Hidden Context Methods
|
|
153
|
+
|
|
154
|
+
Hidden methods mirror visible methods but operate on a separate bucket excluded from `all()`. Use hidden context for sensitive data (tokens, credentials) that should not appear in logs.
|
|
155
|
+
|
|
156
|
+
- `addHidden(key, value)` -- Store in hidden context.
|
|
157
|
+
- `getHidden<Value>(key)` -- Retrieve from hidden context.
|
|
158
|
+
- `hasHidden(key)` -- Check existence in hidden context.
|
|
159
|
+
- `delHidden(...key)` -- Remove from hidden context.
|
|
160
|
+
- `pushHidden(key, ...value)` -- Append to array in hidden context.
|
|
161
|
+
- `allHidden()` -- Return all hidden entries as a plain object.
|
|
162
|
+
- `createHiddenItem<Value>(key)` -- Typed handle for hidden context.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
Context.run(() => {
|
|
166
|
+
Context.addHidden('authToken', 'Bearer xyz')
|
|
167
|
+
Context.getHidden('authToken') // 'Bearer xyz'
|
|
168
|
+
Context.all() // {} -- hidden values excluded
|
|
169
|
+
Context.allHidden() // { authToken: 'Bearer xyz' }
|
|
170
|
+
})
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Provider Methods
|
|
174
|
+
|
|
175
|
+
#### `Context.Provider.create(): Context.ContextService`
|
|
176
|
+
|
|
177
|
+
Factory that instantiates a new context service with its own `AsyncLocalStorage`.
|
|
178
|
+
|
|
179
|
+
#### `Context.Provider.register(name: Context.Provider.Key, service: Context.ContextService): void`
|
|
180
|
+
|
|
181
|
+
Register an instance in the named registry.
|
|
182
|
+
|
|
183
|
+
#### `Context.provider(key: Context.Provider.Key): Context.ContextService`
|
|
184
|
+
|
|
185
|
+
Resolve a named instance. Resolution is deferred via Proxy, so `provider()` can be called before `register()`.
|
|
186
|
+
|
|
187
|
+
## Common Patterns
|
|
188
|
+
|
|
189
|
+
### Request-Scoped Data in HTTP Middleware
|
|
190
|
+
|
|
191
|
+
Wrap each incoming request in `Context.run()` to isolate per-request data:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
function contextMiddleware(req, res, next) {
|
|
195
|
+
Context.run(() => {
|
|
196
|
+
Context.add('requestId', req.headers['x-request-id'])
|
|
197
|
+
Context.add('method', req.method)
|
|
198
|
+
Context.add('path', req.path)
|
|
199
|
+
Context.addHidden('authToken', req.headers.authorization)
|
|
200
|
+
next()
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Propagating Context Across Async Boundaries
|
|
206
|
+
|
|
207
|
+
When context must survive into a callback that runs outside the original `run()` scope (e.g., stream event handlers), capture and restore the store:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
Context.run(() => {
|
|
211
|
+
Context.add('queryId', 'q-001')
|
|
212
|
+
const store = Context.getStore()
|
|
213
|
+
|
|
214
|
+
stream.on('end', () => {
|
|
215
|
+
Context.runWith(store, () => {
|
|
216
|
+
// Context is available here
|
|
217
|
+
Context.get('queryId') // 'q-001'
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
This pattern is used by `@maestro-js/db` for query stream logging and by `@maestro-js/tracing` for span instrumentation.
|
|
224
|
+
|
|
225
|
+
### Nested Runs Create Fresh Stores
|
|
226
|
+
|
|
227
|
+
Each `run()` creates a completely fresh store. Nested calls do not inherit from the outer scope:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
Context.run(() => {
|
|
231
|
+
Context.add('outer', 1)
|
|
232
|
+
Context.run(() => {
|
|
233
|
+
Context.get('outer') // undefined -- fresh store
|
|
234
|
+
Context.add('inner', 2)
|
|
235
|
+
})
|
|
236
|
+
Context.get('inner') // undefined -- inner store is gone
|
|
237
|
+
Context.get('outer') // 1
|
|
238
|
+
})
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Silent No-Ops Outside run()
|
|
242
|
+
|
|
243
|
+
All context operations silently do nothing when called outside a `run()` scope. No errors are thrown:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
Context.add('key', 'value') // no-op
|
|
247
|
+
Context.get('key') // undefined
|
|
248
|
+
Context.has('key') // false
|
|
249
|
+
Context.all() // {}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Store Mutations via runWith Are Shared
|
|
253
|
+
|
|
254
|
+
`getStore()` returns a reference, not a copy. Mutations inside `runWith` affect the original store:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
Context.run(() => {
|
|
258
|
+
Context.add('count', 1)
|
|
259
|
+
const store = Context.getStore()
|
|
260
|
+
|
|
261
|
+
Context.runWith(store, () => {
|
|
262
|
+
Context.add('count', 2)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
Context.get('count') // 2 -- mutated by runWith
|
|
266
|
+
})
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Multiple Isolated Providers
|
|
270
|
+
|
|
271
|
+
Separate `create()` calls produce fully independent context instances:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const appContext = Context.Provider.create()
|
|
275
|
+
const requestContext = Context.Provider.create()
|
|
276
|
+
|
|
277
|
+
appContext.run(() => {
|
|
278
|
+
appContext.add('env', 'production')
|
|
279
|
+
requestContext.run(() => {
|
|
280
|
+
requestContext.add('env', 'staging')
|
|
281
|
+
appContext.get('env') // 'production'
|
|
282
|
+
requestContext.get('env') // 'staging'
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Testing
|
|
288
|
+
|
|
289
|
+
Context requires no mock driver -- `Provider.create()` returns a fully functional instance using in-process `AsyncLocalStorage`. Wrap test logic in `run()`:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
import { Context } from '@maestro-js/context'
|
|
293
|
+
import { test } from 'beartest-js'
|
|
294
|
+
import { expect } from 'expect'
|
|
295
|
+
|
|
296
|
+
test('adds and retrieves context values', () => {
|
|
297
|
+
const ctx = Context.Provider.create()
|
|
298
|
+
ctx.run(() => {
|
|
299
|
+
ctx.add('userId', 123)
|
|
300
|
+
expect(ctx.get('userId')).toBe(123)
|
|
301
|
+
expect(ctx.has('userId')).toBe(true)
|
|
302
|
+
expect(ctx.all()).toEqual({ userId: 123 })
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('hidden values excluded from all()', () => {
|
|
307
|
+
const ctx = Context.Provider.create()
|
|
308
|
+
ctx.run(() => {
|
|
309
|
+
ctx.add('visible', 'yes')
|
|
310
|
+
ctx.addHidden('secret', 'token')
|
|
311
|
+
expect(ctx.all()).toEqual({ visible: 'yes' })
|
|
312
|
+
expect(ctx.getHidden('secret')).toBe('token')
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('async propagation across await', async () => {
|
|
317
|
+
const ctx = Context.Provider.create()
|
|
318
|
+
await ctx.run(async () => {
|
|
319
|
+
ctx.add('before', true)
|
|
320
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
321
|
+
expect(ctx.get('before')).toBe(true)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Run tests with:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
pnpm --filter @maestro-js/context test
|
|
330
|
+
# or
|
|
331
|
+
cd packages/context && npx beartest ./tests/context.test.ts
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Cross-Package Integration
|
|
335
|
+
|
|
336
|
+
### Db (query stream logging)
|
|
337
|
+
|
|
338
|
+
The `@maestro-js/db` package captures the context store before creating query streams and restores it in `end` and `error` event handlers via `Context.runWith()`, ensuring log messages emitted during stream callbacks include the original request context.
|
|
339
|
+
|
|
340
|
+
### Tracing (span instrumentation)
|
|
341
|
+
|
|
342
|
+
The `@maestro-js/tracing` package captures the context store when starting or wrapping spans and restores it in `onEnd` callbacks via `Context.runWith()`, so span completion logs include the request context.
|
|
343
|
+
|
|
344
|
+
### Log (contextual logging)
|
|
345
|
+
|
|
346
|
+
Log transports can call `Context.all()` to attach visible context entries to every log line within a request scope, providing automatic request-scoped structured logging without manually passing data.
|
|
347
|
+
|
|
348
|
+
### Dependencies
|
|
349
|
+
|
|
350
|
+
- **Depends on**: `@maestro-js/service-registry` (Provider pattern foundation)
|
|
351
|
+
- **Used by**: `@maestro-js/db`, `@maestro-js/tracing`
|
|
352
|
+
- **Works with**: `@maestro-js/log` (contextual logging)
|
|
353
|
+
|
|
354
|
+
## Types
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
// Key type for context entries
|
|
358
|
+
type Context.Key = string | number | symbol
|
|
359
|
+
|
|
360
|
+
// The raw context snapshot containing visible and hidden Map buckets
|
|
361
|
+
type Context.Store = {
|
|
362
|
+
hidden: Map<Context.Key, unknown>
|
|
363
|
+
visible: Map<Context.Key, unknown>
|
|
364
|
+
}
|
|
365
|
+
```
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: crypt
|
|
3
|
+
description: "AES-256-GCM encryption and decryption with automatic key rotation for the maestro-js framework. Use when working with @maestro-js/crypt, encrypting or decrypting strings, rotating encryption keys, checking key IDs on encrypted data, or setting up field-level encryption. Key capabilities include AES-256-GCM authenticated encryption, dot-delimited cipher format with embedded key IDs, zero-downtime key rotation via previousKeys, per-call random IV generation, Provider pattern with deferred resolution, and key identification without decryption."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Maestro Crypt
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
The `@maestro-js/crypt` package provides AES-256-GCM encryption and decryption with automatic key rotation. Encrypted strings use a dot-delimited format (`keyId.iv.ciphertext.authTag`) that embeds a short key identifier, allowing the correct key to be selected during decryption even after key rotation.
|
|
11
|
+
|
|
12
|
+
## Quick Setup
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { Crypt } from '@maestro-js/crypt'
|
|
16
|
+
import crypto from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
// Create a service with a 32-byte base64-encoded key
|
|
19
|
+
const service = Crypt.Provider.create({
|
|
20
|
+
key: process.env.ENCRYPTION_KEY!, // base64-encoded, 32 bytes decoded
|
|
21
|
+
previousKeys: [] // no rotated keys yet
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Register as the default provider
|
|
25
|
+
Crypt.Provider.register('default', service)
|
|
26
|
+
|
|
27
|
+
// Encrypt and decrypt
|
|
28
|
+
const encrypted = Crypt.encryptString('sensitive data')
|
|
29
|
+
const plaintext = Crypt.decryptString(encrypted) // 'sensitive data'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Generate a valid key: `crypto.randomBytes(32).toString('base64')`
|
|
33
|
+
|
|
34
|
+
## API Reference
|
|
35
|
+
|
|
36
|
+
### `Crypt.Provider.create(config)`
|
|
37
|
+
|
|
38
|
+
Create a new Crypt service instance from a keyset.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
function create(config: {
|
|
42
|
+
key: string // current primary key, base64-encoded (32 bytes decoded)
|
|
43
|
+
previousKeys: string[] // previously-used keys for decryption, base64-encoded
|
|
44
|
+
}): Crypt.CryptService
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Each key is assigned a short ID (first 4 characters of its base64 SHA-256 hash). Keys must be exactly 32 bytes when decoded from base64.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const service = Crypt.Provider.create({
|
|
51
|
+
key: 'aGVsbG93b3JsZGhlbGxvd29ybGRoZWxsb3dvcmxkMTI=',
|
|
52
|
+
previousKeys: []
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### `Crypt.Provider.register(name, service)`
|
|
57
|
+
|
|
58
|
+
Register a service instance in the named registry.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
Crypt.Provider.register('default', service)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### `Crypt.provider(key)`
|
|
65
|
+
|
|
66
|
+
Resolve a named provider instance. The default facade (`Crypt.encryptString`, etc.) delegates to `Crypt.provider('default')`.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const fieldCrypt = Crypt.provider('field-level')
|
|
70
|
+
const encrypted = fieldCrypt.encryptString('SSN: 123-45-6789')
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### `encryptString(plaintext)`
|
|
74
|
+
|
|
75
|
+
Encrypt a plaintext string using AES-256-GCM with the current primary key. Generates a random 12-byte IV per call, so encrypting the same string twice produces different ciphertext.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
encryptString(plaintext: string): string
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Returns a dot-delimited string: `{keyId}.{iv}.{ciphertext}.{authTag}`.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const token1 = Crypt.encryptString('user-session-abc')
|
|
85
|
+
const token2 = Crypt.encryptString('user-session-abc')
|
|
86
|
+
// token1 !== token2 (different IVs)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### `decryptString(encryptedText)`
|
|
90
|
+
|
|
91
|
+
Decrypt a dot-delimited cipher string. Parses the embedded key ID and tries all keys in the keyset with a matching ID. Throws if no key can decrypt the text.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
decryptString(encryptedText: string): string
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const plaintext = Crypt.decryptString(encrypted) // 'user-session-abc'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### `getEncryptingKeyId(encryptedText)`
|
|
102
|
+
|
|
103
|
+
Extract the key ID from an encrypted string without decrypting it. Useful for checking whether data needs re-encryption after a key rotation.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
getEncryptingKeyId(encryptedText: string): string | undefined
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const keyId = Crypt.getEncryptingKeyId(encrypted) // e.g. 'aB3d'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### `getCurrentKeyId()`
|
|
114
|
+
|
|
115
|
+
Return the ID of the current primary encryption key.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
getCurrentKeyId(): string
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const currentId = Crypt.getCurrentKeyId() // e.g. 'aB3d'
|
|
123
|
+
|
|
124
|
+
// Check if data needs re-encryption
|
|
125
|
+
if (Crypt.getEncryptingKeyId(encrypted) !== Crypt.getCurrentKeyId()) {
|
|
126
|
+
const refreshed = Crypt.encryptString(Crypt.decryptString(encrypted))
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Key Rotation
|
|
131
|
+
|
|
132
|
+
Key rotation allows transitioning to a new encryption key without breaking decryption of existing data. Move the old key into `previousKeys` and set the new key as `key`.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// Before rotation: key1 is the primary key
|
|
136
|
+
const service = Crypt.Provider.create({
|
|
137
|
+
key: key1,
|
|
138
|
+
previousKeys: []
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// After rotation: key2 is primary, key1 still decrypts old data
|
|
142
|
+
const rotatedService = Crypt.Provider.create({
|
|
143
|
+
key: key2,
|
|
144
|
+
previousKeys: [key1]
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// New encryptions use key2
|
|
148
|
+
const newEncrypted = rotatedService.encryptString('new data')
|
|
149
|
+
|
|
150
|
+
// Old data encrypted with key1 still decrypts
|
|
151
|
+
const oldPlaintext = rotatedService.decryptString(oldEncryptedWithKey1)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Re-encrypting Existing Data
|
|
155
|
+
|
|
156
|
+
After rotation, identify and re-encrypt data that uses old keys:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
function reEncryptIfNeeded(encryptedValue: string): string {
|
|
160
|
+
if (Crypt.getEncryptingKeyId(encryptedValue) !== Crypt.getCurrentKeyId()) {
|
|
161
|
+
return Crypt.encryptString(Crypt.decryptString(encryptedValue))
|
|
162
|
+
}
|
|
163
|
+
return encryptedValue
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Common Patterns
|
|
168
|
+
|
|
169
|
+
### Field-Level Encryption in Database Records
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
// Encrypt before storing
|
|
173
|
+
const encryptedSsn = Crypt.encryptString('123-45-6789')
|
|
174
|
+
await Db.query('INSERT INTO users (name, ssn_encrypted) VALUES (?, ?)', ['Alice', encryptedSsn])
|
|
175
|
+
|
|
176
|
+
// Decrypt after reading
|
|
177
|
+
const [row] = await Db.query('SELECT ssn_encrypted FROM users WHERE id = ?', [userId])
|
|
178
|
+
const ssn = Crypt.decryptString(row.ssn_encrypted) // '123-45-6789'
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Multiple Named Providers
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// Separate keysets for different sensitivity levels
|
|
185
|
+
Crypt.Provider.register('default', Crypt.Provider.create({
|
|
186
|
+
key: process.env.GENERAL_ENCRYPTION_KEY!,
|
|
187
|
+
previousKeys: []
|
|
188
|
+
}))
|
|
189
|
+
|
|
190
|
+
Crypt.Provider.register('pii', Crypt.Provider.create({
|
|
191
|
+
key: process.env.PII_ENCRYPTION_KEY!,
|
|
192
|
+
previousKeys: [process.env.PII_OLD_KEY!]
|
|
193
|
+
}))
|
|
194
|
+
|
|
195
|
+
// Use the PII provider for sensitive fields
|
|
196
|
+
const piiCrypt = Crypt.provider('pii')
|
|
197
|
+
const encryptedSsn = piiCrypt.encryptString(ssn)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Encrypted String Format
|
|
201
|
+
|
|
202
|
+
The dot-delimited format is: `{keyId}.{iv}.{ciphertext}.{authTag}`
|
|
203
|
+
|
|
204
|
+
- **keyId** -- first 4 chars of the key's base64 SHA-256 hash
|
|
205
|
+
- **iv** -- random 12-byte initialization vector, base64-encoded
|
|
206
|
+
- **ciphertext** -- AES-256-GCM encrypted data, base64-encoded
|
|
207
|
+
- **authTag** -- GCM authentication tag, base64-encoded
|
|
208
|
+
|
|
209
|
+
## Testing
|
|
210
|
+
|
|
211
|
+
Use `Crypt.Provider.create` directly in tests with generated keys. No mock driver is needed since encryption is CPU-only with no external dependencies.
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
import crypto from 'node:crypto'
|
|
215
|
+
import { Crypt } from '@maestro-js/crypt'
|
|
216
|
+
import { test } from 'beartest-js'
|
|
217
|
+
import { expect } from 'expect'
|
|
218
|
+
|
|
219
|
+
test('encrypt and decrypt a value', () => {
|
|
220
|
+
const key = crypto.randomBytes(32).toString('base64')
|
|
221
|
+
const service = Crypt.Provider.create({ key, previousKeys: [] })
|
|
222
|
+
|
|
223
|
+
const encrypted = service.encryptString('sensitive-token')
|
|
224
|
+
const decrypted = service.decryptString(encrypted)
|
|
225
|
+
|
|
226
|
+
expect(decrypted).toBe('sensitive-token')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('key rotation decrypts old and new data', () => {
|
|
230
|
+
const key1 = crypto.randomBytes(32).toString('base64')
|
|
231
|
+
const key2 = crypto.randomBytes(32).toString('base64')
|
|
232
|
+
|
|
233
|
+
const oldService = Crypt.Provider.create({ key: key1, previousKeys: [] })
|
|
234
|
+
const oldEncrypted = oldService.encryptString('original-secret')
|
|
235
|
+
|
|
236
|
+
const newService = Crypt.Provider.create({ key: key2, previousKeys: [key1] })
|
|
237
|
+
const newEncrypted = newService.encryptString('new-secret')
|
|
238
|
+
|
|
239
|
+
expect(newService.decryptString(oldEncrypted)).toBe('original-secret')
|
|
240
|
+
expect(newService.decryptString(newEncrypted)).toBe('new-secret')
|
|
241
|
+
expect(newService.getEncryptingKeyId(newEncrypted)).not.toBe(
|
|
242
|
+
newService.getEncryptingKeyId(oldEncrypted)
|
|
243
|
+
)
|
|
244
|
+
})
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Run tests: `pnpm --filter @maestro-js/crypt test`
|
|
248
|
+
|
|
249
|
+
## Cross-Package Integration
|
|
250
|
+
|
|
251
|
+
- **Depends on**: `@maestro-js/service-registry` for the Provider pattern (registry, deferred proxy resolution)
|
|
252
|
+
- **Architectural sibling**: `@maestro-js/hash` follows the same single-driver Provider pattern
|
|
253
|
+
- **Standalone**: No downstream maestro packages depend on Crypt directly; it is used at the application layer for field-level encryption
|