@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.
@@ -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