@intflows/genkit-guard 0.0.10 → 0.0.12

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/README.md CHANGED
@@ -203,45 +203,48 @@ pii: {
203
203
  By default, PII is stored in an in-memory vault scoped to a single tokenizer instance. Tokens include a generated vault scope:
204
204
 
205
205
  ```txt
206
- "Email john.doe@example.com" -> "Email [[EMAIL_<namespace>_0]]"
206
+ "Email john.doe@example.com" -> "Email [[EMAIL_<namespace>_0]]"
207
207
  ```
208
208
 
209
- That generated namespace prevents two concurrent calls from sharing the same visible placeholder names. Vault lookups are isolated by the configured storage scope, so User A and User B can safely produce their own email tokens without cross-resolving each other's PII.
209
+ That generated namespace prevents two concurrent calls from sharing the same visible placeholder names. Vault lookups are isolated by the configured storage scope, so User A and User B can safely produce their own email tokens without cross-resolving each other's PII.
210
210
 
211
- For applications that need persistence, distributed workers, audits, or tenant-specific storage, provide a vault storage backend:
211
+ For applications that need persistence, distributed workers, audits, or tenant-specific storage, provide a vault storage backend. Redis clients can be passed through the built-in helper:
212
+ For applications that need persistence, distributed workers, audits, or tenant-specific storage, provide a vault storage backend. Redis clients can be passed through the built-in helper:
212
213
 
213
214
  ```ts
214
- import { guard, type PiiVaultStorage } from "@intflows/genkit-guard";
215
+ import { createClient } from "redis";
216
+ import { guard, createRedisPiiVaultStorage } from "@intflows/genkit-guard";
215
217
 
216
- class RedisPiiVaultStorage implements PiiVaultStorage {
217
- constructor(private redis: RedisClient) {}
218
-
219
- async get(scopeId: string, token: string) {
220
- return this.redis.hget(`pii:${scopeId}`, token);
221
- }
222
-
223
- async set(scopeId: string, token: string, value: string) {
224
- await this.redis.hset(`pii:${scopeId}`, token, value);
225
- }
226
-
227
- async entries(scopeId: string) {
228
- const values = await this.redis.hgetall(`pii:${scopeId}`);
229
- return Object.entries(values).map(([token, value]) => ({ token, value }));
230
- }
231
- }
218
+ const redis = createClient({ url: "redis://localhost:6379" });
219
+ await redis.connect();
232
220
 
233
221
  guard({
234
222
  pii: {
235
223
  reversible: true,
236
224
  vault: {
237
- storage: new RedisPiiVaultStorage(redis),
225
+ storage: createRedisPiiVaultStorage(redis, {
226
+ keyPrefix: "my-app:pii",
227
+ ttlSeconds: 3600,
228
+ fallbackToMemory: true
229
+ }),
238
230
  scopeId: (req, ctx) => ctx?.auth?.sessionId ?? req?.metadata?.requestId
239
231
  }
240
232
  }
241
233
  });
242
234
  ```
243
235
 
244
- Choose a `scopeId` that matches your isolation boundary, such as request ID, session ID, tenant/user ID, or a combination like `tenantId:userId:requestId`. A shared external backend should never ignore `scopeId`, because placeholders are only safe when resolved against the correct vault scope. The placeholder sent to the model uses an opaque generated namespace rather than exposing your `scopeId`.
236
+ `ttlSeconds` applies the configured expiry to both the scoped vault and token index. When
237
+ `fallbackToMemory` is enabled, successful writes are also mirrored in process memory and Redis
238
+ operation failures fall back to that mirror. The fallback is disabled by default, is local to one
239
+ process, and is not a replacement for Redis persistence or multi-worker availability. Its in-memory
240
+ entries observe the same TTL. Redis errors continue to propagate when fallback is disabled.
241
+
242
+ For another backend, use `createPiiVaultStorage({ get, set, entries, getByToken })` with your database, cache, or secret store.
243
+
244
+ Choose a `scopeId` that matches your isolation boundary, such as request ID, session ID, tenant/user ID, or a combination like `tenantId:userId:requestId`. A shared external backend should never ignore `scopeId`, because placeholders are only safe when resolved against the correct vault scope. The placeholder sent to the model uses an opaque generated namespace rather than exposing your `scopeId`.
245
+
246
+ ### Screenshots
247
+ ![Redis Stored PII ](redis-scan.png)
245
248
 
246
249
  ---
247
250
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { guard, guardAction, guardMiddleware, guardPlugin } from './middleware/middleware.js';
2
- export { InMemoryPiiVaultStorage, defaultPiiVaultStorage } from './pii/storage.js';
3
- export type { PiiVaultEntry, PiiVaultStorage } from './pii/storage.js';
2
+ export type { GuardConfig } from './middleware/middleware.js';
3
+ export { InMemoryPiiVaultStorage, createPiiVaultStorage, createRedisPiiVaultStorage, defaultPiiVaultStorage, } from './pii/storage.js';
4
+ export type { PiiVaultEntry, PiiVaultStorage, PiiVaultStorageAdapter, RedisPiiVaultClient, RedisPiiVaultStorageOptions, } from './pii/storage.js';
4
5
  export * from './core/types.js';
5
6
  /**
6
7
  * Pre-load the model to avoid cold-start delay on first user request.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ModelSingleton } from './util/singleton.js';
2
2
  // export { intentGuard, piiGuard } from './middleware/middleware.js';
3
3
  export { guard, guardAction, guardMiddleware, guardPlugin } from './middleware/middleware.js';
4
- export { InMemoryPiiVaultStorage, defaultPiiVaultStorage } from './pii/storage.js';
4
+ export { InMemoryPiiVaultStorage, createPiiVaultStorage, createRedisPiiVaultStorage, defaultPiiVaultStorage, } from './pii/storage.js';
5
5
  export * from './core/types.js';
6
6
  function logGuardEvent(eventName, body, attributes = {}) {
7
7
  console.log(JSON.stringify({
@@ -1,254 +1,34 @@
1
1
  import { z } from 'genkit';
2
2
  import { type PiiVaultStorage } from '../pii/storage.js';
3
- declare const guardConfigSchema: z.ZodObject<{
4
- intent: z.ZodOptional<z.ZodObject<{
5
- mode: z.ZodOptional<z.ZodString>;
6
- allowedIntent: z.ZodOptional<z.ZodString>;
7
- semantic: z.ZodObject<{
8
- threshold: z.ZodOptional<z.ZodNumber>;
9
- intents: z.ZodRecord<z.ZodString, z.ZodString>;
10
- }, "strip", z.ZodTypeAny, {
11
- intents: Record<string, string>;
12
- threshold?: number | undefined;
13
- }, {
14
- intents: Record<string, string>;
15
- threshold?: number | undefined;
16
- }>;
17
- }, "strip", z.ZodTypeAny, {
18
- semantic: {
19
- intents: Record<string, string>;
20
- threshold?: number | undefined;
21
- };
22
- mode?: string | undefined;
23
- allowedIntent?: string | undefined;
24
- }, {
25
- semantic: {
26
- intents: Record<string, string>;
27
- threshold?: number | undefined;
28
- };
29
- mode?: string | undefined;
30
- allowedIntent?: string | undefined;
31
- }>>;
32
- pii: z.ZodOptional<z.ZodObject<{
33
- reversible: z.ZodOptional<z.ZodBoolean>;
34
- model: z.ZodOptional<z.ZodString>;
35
- mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
36
- vault: z.ZodOptional<z.ZodObject<{
37
- storage: z.ZodOptional<z.ZodAny>;
38
- scopeId: z.ZodOptional<z.ZodAny>;
39
- }, "strip", z.ZodTypeAny, {
40
- storage?: any;
41
- scopeId?: any;
42
- }, {
43
- storage?: any;
44
- scopeId?: any;
45
- }>>;
46
- }, "strip", z.ZodTypeAny, {
47
- mode?: "ner" | "classifier" | undefined;
48
- reversible?: boolean | undefined;
49
- model?: string | undefined;
50
- vault?: {
51
- storage?: any;
52
- scopeId?: any;
53
- } | undefined;
54
- }, {
55
- mode?: "ner" | "classifier" | undefined;
56
- reversible?: boolean | undefined;
57
- model?: string | undefined;
58
- vault?: {
59
- storage?: any;
60
- scopeId?: any;
61
- } | undefined;
62
- }>>;
63
- logging: z.ZodOptional<z.ZodObject<{
64
- enabled: z.ZodOptional<z.ZodBoolean>;
65
- level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
66
- serviceName: z.ZodOptional<z.ZodString>;
67
- }, "strip", z.ZodTypeAny, {
68
- enabled?: boolean | undefined;
69
- level?: "debug" | "info" | "warn" | "error" | undefined;
70
- serviceName?: string | undefined;
71
- }, {
72
- enabled?: boolean | undefined;
73
- level?: "debug" | "info" | "warn" | "error" | undefined;
74
- serviceName?: string | undefined;
75
- }>>;
76
- models: z.ZodOptional<z.ZodObject<{
77
- extractor: z.ZodOptional<z.ZodString>;
78
- }, "strip", z.ZodTypeAny, {
79
- extractor?: string | undefined;
80
- }, {
81
- extractor?: string | undefined;
82
- }>>;
83
- }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
84
- intent: z.ZodOptional<z.ZodObject<{
85
- mode: z.ZodOptional<z.ZodString>;
86
- allowedIntent: z.ZodOptional<z.ZodString>;
87
- semantic: z.ZodObject<{
88
- threshold: z.ZodOptional<z.ZodNumber>;
89
- intents: z.ZodRecord<z.ZodString, z.ZodString>;
90
- }, "strip", z.ZodTypeAny, {
91
- intents: Record<string, string>;
92
- threshold?: number | undefined;
93
- }, {
94
- intents: Record<string, string>;
95
- threshold?: number | undefined;
96
- }>;
97
- }, "strip", z.ZodTypeAny, {
98
- semantic: {
99
- intents: Record<string, string>;
100
- threshold?: number | undefined;
101
- };
102
- mode?: string | undefined;
103
- allowedIntent?: string | undefined;
104
- }, {
105
- semantic: {
106
- intents: Record<string, string>;
107
- threshold?: number | undefined;
108
- };
109
- mode?: string | undefined;
110
- allowedIntent?: string | undefined;
111
- }>>;
112
- pii: z.ZodOptional<z.ZodObject<{
113
- reversible: z.ZodOptional<z.ZodBoolean>;
114
- model: z.ZodOptional<z.ZodString>;
115
- mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
116
- vault: z.ZodOptional<z.ZodObject<{
117
- storage: z.ZodOptional<z.ZodAny>;
118
- scopeId: z.ZodOptional<z.ZodAny>;
119
- }, "strip", z.ZodTypeAny, {
120
- storage?: any;
121
- scopeId?: any;
122
- }, {
123
- storage?: any;
124
- scopeId?: any;
125
- }>>;
126
- }, "strip", z.ZodTypeAny, {
127
- mode?: "ner" | "classifier" | undefined;
128
- reversible?: boolean | undefined;
129
- model?: string | undefined;
130
- vault?: {
131
- storage?: any;
132
- scopeId?: any;
133
- } | undefined;
134
- }, {
135
- mode?: "ner" | "classifier" | undefined;
136
- reversible?: boolean | undefined;
137
- model?: string | undefined;
138
- vault?: {
139
- storage?: any;
140
- scopeId?: any;
141
- } | undefined;
142
- }>>;
143
- logging: z.ZodOptional<z.ZodObject<{
144
- enabled: z.ZodOptional<z.ZodBoolean>;
145
- level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
146
- serviceName: z.ZodOptional<z.ZodString>;
147
- }, "strip", z.ZodTypeAny, {
148
- enabled?: boolean | undefined;
149
- level?: "debug" | "info" | "warn" | "error" | undefined;
150
- serviceName?: string | undefined;
151
- }, {
152
- enabled?: boolean | undefined;
153
- level?: "debug" | "info" | "warn" | "error" | undefined;
154
- serviceName?: string | undefined;
155
- }>>;
156
- models: z.ZodOptional<z.ZodObject<{
157
- extractor: z.ZodOptional<z.ZodString>;
158
- }, "strip", z.ZodTypeAny, {
159
- extractor?: string | undefined;
160
- }, {
161
- extractor?: string | undefined;
162
- }>>;
163
- }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
164
- intent: z.ZodOptional<z.ZodObject<{
165
- mode: z.ZodOptional<z.ZodString>;
166
- allowedIntent: z.ZodOptional<z.ZodString>;
167
- semantic: z.ZodObject<{
168
- threshold: z.ZodOptional<z.ZodNumber>;
169
- intents: z.ZodRecord<z.ZodString, z.ZodString>;
170
- }, "strip", z.ZodTypeAny, {
171
- intents: Record<string, string>;
172
- threshold?: number | undefined;
173
- }, {
174
- intents: Record<string, string>;
175
- threshold?: number | undefined;
176
- }>;
177
- }, "strip", z.ZodTypeAny, {
178
- semantic: {
179
- intents: Record<string, string>;
180
- threshold?: number | undefined;
181
- };
182
- mode?: string | undefined;
183
- allowedIntent?: string | undefined;
184
- }, {
185
- semantic: {
3
+ export type GuardConfig = {
4
+ intent?: {
5
+ mode?: string;
6
+ allowedIntent?: string;
7
+ semantic?: {
8
+ threshold?: number;
186
9
  intents: Record<string, string>;
187
- threshold?: number | undefined;
188
10
  };
189
- mode?: string | undefined;
190
- allowedIntent?: string | undefined;
191
- }>>;
192
- pii: z.ZodOptional<z.ZodObject<{
193
- reversible: z.ZodOptional<z.ZodBoolean>;
194
- model: z.ZodOptional<z.ZodString>;
195
- mode: z.ZodOptional<z.ZodEnum<["ner", "classifier"]>>;
196
- vault: z.ZodOptional<z.ZodObject<{
197
- storage: z.ZodOptional<z.ZodAny>;
198
- scopeId: z.ZodOptional<z.ZodAny>;
199
- }, "strip", z.ZodTypeAny, {
200
- storage?: any;
201
- scopeId?: any;
202
- }, {
203
- storage?: any;
204
- scopeId?: any;
205
- }>>;
206
- }, "strip", z.ZodTypeAny, {
207
- mode?: "ner" | "classifier" | undefined;
208
- reversible?: boolean | undefined;
209
- model?: string | undefined;
210
- vault?: {
211
- storage?: any;
212
- scopeId?: any;
213
- } | undefined;
214
- }, {
215
- mode?: "ner" | "classifier" | undefined;
216
- reversible?: boolean | undefined;
217
- model?: string | undefined;
218
- vault?: {
219
- storage?: any;
220
- scopeId?: any;
221
- } | undefined;
222
- }>>;
223
- logging: z.ZodOptional<z.ZodObject<{
224
- enabled: z.ZodOptional<z.ZodBoolean>;
225
- level: z.ZodOptional<z.ZodEnum<["debug", "info", "warn", "error"]>>;
226
- serviceName: z.ZodOptional<z.ZodString>;
227
- }, "strip", z.ZodTypeAny, {
228
- enabled?: boolean | undefined;
229
- level?: "debug" | "info" | "warn" | "error" | undefined;
230
- serviceName?: string | undefined;
231
- }, {
232
- enabled?: boolean | undefined;
233
- level?: "debug" | "info" | "warn" | "error" | undefined;
234
- serviceName?: string | undefined;
235
- }>>;
236
- models: z.ZodOptional<z.ZodObject<{
237
- extractor: z.ZodOptional<z.ZodString>;
238
- }, "strip", z.ZodTypeAny, {
239
- extractor?: string | undefined;
240
- }, {
241
- extractor?: string | undefined;
242
- }>>;
243
- }, z.ZodTypeAny, "passthrough">>;
244
- type GuardConfig = z.infer<typeof guardConfigSchema> & {
11
+ };
245
12
  pii?: {
13
+ reversible?: boolean;
14
+ model?: string;
15
+ mode?: 'ner' | 'classifier';
246
16
  vault?: {
247
17
  storage?: PiiVaultStorage;
248
18
  scopeId?: string | ((req: any, ctx: any) => string | undefined);
249
19
  };
250
20
  };
21
+ logging?: {
22
+ enabled?: boolean;
23
+ level?: LogSeverity;
24
+ serviceName?: string;
25
+ };
26
+ models?: {
27
+ extractor?: string;
28
+ };
29
+ [key: string]: any;
251
30
  };
31
+ type LogSeverity = 'debug' | 'info' | 'warn' | 'error';
252
32
  export declare const guardMiddleware: import("genkit").GenerateMiddleware<z.ZodObject<{
253
33
  intent: z.ZodOptional<z.ZodObject<{
254
34
  mode: z.ZodOptional<z.ZodString>;
@@ -8,6 +8,34 @@ export interface PiiVaultStorage {
8
8
  set(scopeId: string, token: string, value: string): void | Promise<void>;
9
9
  entries(scopeId: string): PiiVaultEntry[] | Promise<PiiVaultEntry[]>;
10
10
  }
11
+ export type PiiVaultStorageAdapter = {
12
+ get: PiiVaultStorage['get'];
13
+ getByToken?: PiiVaultStorage['getByToken'];
14
+ set: PiiVaultStorage['set'];
15
+ entries: PiiVaultStorage['entries'];
16
+ };
17
+ export declare function createPiiVaultStorage(adapter: PiiVaultStorageAdapter): PiiVaultStorage;
18
+ export type RedisPiiVaultClient = {
19
+ hGet?: (key: string, field: string) => Promise<string | null | undefined>;
20
+ hSet?: (key: string, field: string, value: string) => Promise<unknown>;
21
+ hGetAll?: (key: string) => Promise<Record<string, string>>;
22
+ hget?: (key: string, field: string) => Promise<string | null | undefined>;
23
+ hset?: (key: string, field: string, value: string) => Promise<unknown>;
24
+ hgetall?: (key: string) => Promise<Record<string, string>>;
25
+ expire?: (key: string, seconds: number) => Promise<unknown>;
26
+ };
27
+ export type RedisPiiVaultStorageOptions = {
28
+ keyPrefix?: string;
29
+ tokenIndexKey?: string;
30
+ /** Expire Redis vault keys after this many seconds. Omit to keep them indefinitely. */
31
+ ttlSeconds?: number;
32
+ /**
33
+ * Keep a process-local mirror and use it when a Redis operation fails.
34
+ * Disabled by default so Redis failures remain visible to callers.
35
+ */
36
+ fallbackToMemory?: boolean;
37
+ };
38
+ export declare function createRedisPiiVaultStorage(redis: RedisPiiVaultClient, options?: RedisPiiVaultStorageOptions): PiiVaultStorage;
11
39
  export declare class InMemoryPiiVaultStorage implements PiiVaultStorage {
12
40
  private scopes;
13
41
  private tokenIndex;
@@ -1,3 +1,98 @@
1
+ export function createPiiVaultStorage(adapter) {
2
+ return adapter;
3
+ }
4
+ export function createRedisPiiVaultStorage(redis, options = {}) {
5
+ const keyPrefix = options.keyPrefix ?? 'genkit-guard:pii';
6
+ const tokenIndexKey = options.tokenIndexKey ?? `${keyPrefix}:tokens`;
7
+ const ttlSeconds = options.ttlSeconds;
8
+ if (ttlSeconds !== undefined && (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0)) {
9
+ throw new Error('Redis PII vault ttlSeconds must be a positive integer.');
10
+ }
11
+ if (ttlSeconds !== undefined && !redis.expire) {
12
+ throw new Error('Redis PII vault ttlSeconds requires an expire method on the Redis client.');
13
+ }
14
+ const hGet = redis.hGet?.bind(redis) ?? redis.hget?.bind(redis);
15
+ const hSet = redis.hSet?.bind(redis) ?? redis.hset?.bind(redis);
16
+ const hGetAll = redis.hGetAll?.bind(redis) ?? redis.hgetall?.bind(redis);
17
+ if (!hGet || !hSet || !hGetAll) {
18
+ throw new Error('Redis PII vault storage requires hGet/hSet/hGetAll or hget/hset/hgetall methods.');
19
+ }
20
+ const scopeKey = (scopeId) => `${keyPrefix}:scope:${scopeId}`;
21
+ const fallback = options.fallbackToMemory ? new ExpiringInMemoryPiiVaultStorage(ttlSeconds) : undefined;
22
+ async function maybeExpire(key) {
23
+ if (ttlSeconds !== undefined) {
24
+ await redis.expire(key, ttlSeconds);
25
+ }
26
+ }
27
+ async function withFallback(redisOperation, memoryOperation) {
28
+ try {
29
+ return await redisOperation();
30
+ }
31
+ catch (error) {
32
+ if (!fallback)
33
+ throw error;
34
+ return memoryOperation();
35
+ }
36
+ }
37
+ return createPiiVaultStorage({
38
+ async get(scopeId, token) {
39
+ return withFallback(async () => (await hGet(scopeKey(scopeId), token)) ?? undefined, () => fallback.get(scopeId, token));
40
+ },
41
+ async getByToken(token) {
42
+ return withFallback(async () => (await hGet(tokenIndexKey, token)) ?? undefined, () => fallback.getByToken(token));
43
+ },
44
+ async set(scopeId, token, value) {
45
+ const scopedKey = scopeKey(scopeId);
46
+ // Warm the opt-in fallback on every write so data written before an outage is available.
47
+ await fallback?.set(scopeId, token, value);
48
+ await withFallback(async () => {
49
+ await hSet(scopedKey, token, value);
50
+ await hSet(tokenIndexKey, token, value);
51
+ await maybeExpire(scopedKey);
52
+ await maybeExpire(tokenIndexKey);
53
+ }, () => undefined);
54
+ },
55
+ async entries(scopeId) {
56
+ return withFallback(async () => {
57
+ const values = await hGetAll(scopeKey(scopeId));
58
+ return Object.entries(values).map(([token, value]) => ({ token, value }));
59
+ }, () => fallback.entries(scopeId));
60
+ },
61
+ });
62
+ }
63
+ class ExpiringInMemoryPiiVaultStorage {
64
+ ttlSeconds;
65
+ storage = new InMemoryPiiVaultStorage();
66
+ scopeExpiresAt = new Map();
67
+ tokenIndexExpiresAt;
68
+ constructor(ttlSeconds) {
69
+ this.ttlSeconds = ttlSeconds;
70
+ }
71
+ get(scopeId, token) {
72
+ return this.isScopeExpired(scopeId) ? undefined : this.storage.get(scopeId, token);
73
+ }
74
+ getByToken(token) {
75
+ return this.isTokenIndexExpired() ? undefined : this.storage.getByToken(token);
76
+ }
77
+ set(scopeId, token, value) {
78
+ this.storage.set(scopeId, token, value);
79
+ if (this.ttlSeconds !== undefined) {
80
+ const expiry = Date.now() + this.ttlSeconds * 1_000;
81
+ this.scopeExpiresAt.set(scopeId, expiry);
82
+ this.tokenIndexExpiresAt = expiry;
83
+ }
84
+ }
85
+ entries(scopeId) {
86
+ return this.isScopeExpired(scopeId) ? [] : this.storage.entries(scopeId);
87
+ }
88
+ isScopeExpired(scopeId) {
89
+ const expiry = this.scopeExpiresAt.get(scopeId);
90
+ return expiry !== undefined && expiry <= Date.now();
91
+ }
92
+ isTokenIndexExpired() {
93
+ return this.tokenIndexExpiresAt !== undefined && this.tokenIndexExpiresAt <= Date.now();
94
+ }
95
+ }
1
96
  export class InMemoryPiiVaultStorage {
2
97
  scopes = new Map();
3
98
  tokenIndex = new Map();
package/package.json CHANGED
@@ -22,7 +22,7 @@
22
22
  "huggingface"
23
23
  ],
24
24
  "license": "Apache-2.0",
25
- "version": "0.0.10",
25
+ "version": "0.0.12",
26
26
  "type": "module",
27
27
  "exports": "./dist/index.js",
28
28
  "types": "./dist/index.d.ts",
@@ -32,12 +32,16 @@
32
32
  "scripts/",
33
33
  "dist/"
34
34
  ],
35
- "scripts": {
36
- "prepare-models": "node scripts/download-model.js",
37
- "build": "tsc",
38
- "test:concurrency": "npm run build && node scripts/test-concurrent-vault.js",
39
- "prepublishOnly": "npm run build"
40
- },
35
+ "scripts": {
36
+ "prepare-models": "node scripts/download-model.js",
37
+ "build": "tsc",
38
+ "test": "npm run test:types && npm run test:storage && npm run test:concurrency",
39
+ "test:types": "tsc --noEmit --ignoreConfig --module NodeNext --moduleResolution NodeNext --target ESNext --strict --skipLibCheck scripts/test-types.ts",
40
+ "test:storage": "npm run build && node scripts/test-storage.js",
41
+ "test:concurrency": "npm run build && node scripts/test-concurrent-vault.js",
42
+ "test:redis": "npm run build && node scripts/test-redis-vault.js",
43
+ "prepublishOnly": "npm run build"
44
+ },
41
45
  "dependencies": {
42
46
  "@huggingface/transformers": "^4.2.0",
43
47
  "zod": "^4.4.3"
@@ -0,0 +1,153 @@
1
+ import assert from 'node:assert/strict';
2
+ import net from 'node:net';
3
+ import { createRedisPiiVaultStorage } from '../dist/index.js';
4
+ import { PiiTokenizer } from '../dist/pii/tokenizer.js';
5
+
6
+ class RespRedisClient {
7
+ constructor(host = '127.0.0.1', port = 6379) {
8
+ this.host = host;
9
+ this.port = port;
10
+ }
11
+
12
+ async hGet(key, field) {
13
+ return this.command('HGET', key, field);
14
+ }
15
+
16
+ async hSet(key, field, value) {
17
+ return this.command('HSET', key, field, value);
18
+ }
19
+
20
+ async hGetAll(key) {
21
+ const values = await this.command('HGETALL', key);
22
+ const out = {};
23
+ for (let i = 0; i < values.length; i += 2) {
24
+ out[values[i]] = values[i + 1];
25
+ }
26
+ return out;
27
+ }
28
+
29
+ async expire(key, seconds) {
30
+ return this.command('EXPIRE', key, String(seconds));
31
+ }
32
+
33
+ async del(key) {
34
+ return this.command('DEL', key);
35
+ }
36
+
37
+ command(...parts) {
38
+ return new Promise((resolve, reject) => {
39
+ const socket = net.createConnection({ host: this.host, port: this.port });
40
+ let buffer = Buffer.alloc(0);
41
+
42
+ socket.setTimeout(3000);
43
+ socket.once('error', reject);
44
+ socket.once('timeout', () => {
45
+ socket.destroy();
46
+ reject(new Error(`Timed out connecting to Redis at ${this.host}:${this.port}`));
47
+ });
48
+ socket.once('connect', () => {
49
+ socket.write(encodeCommand(parts));
50
+ });
51
+ socket.on('data', (chunk) => {
52
+ buffer = Buffer.concat([buffer, chunk]);
53
+ try {
54
+ const [value, offset] = parseResp(buffer, 0);
55
+ if (offset <= buffer.length) {
56
+ socket.end();
57
+ resolve(value);
58
+ }
59
+ } catch (error) {
60
+ if (error.message !== 'Incomplete RESP response') {
61
+ socket.destroy();
62
+ reject(error);
63
+ }
64
+ }
65
+ });
66
+ });
67
+ }
68
+ }
69
+
70
+ const redis = new RespRedisClient(process.env.REDIS_HOST ?? '127.0.0.1', Number(process.env.REDIS_PORT ?? 6379));
71
+ const keyPrefix = `genkit-guard:test:${Date.now()}`;
72
+ const storage = createRedisPiiVaultStorage(redis, { keyPrefix, ttlSeconds: 60 });
73
+
74
+ try {
75
+ const userA = new PiiTokenizer({ scopeId: 'tenant:userA', storage });
76
+ const userB = new PiiTokenizer({ scopeId: 'tenant:userB', storage });
77
+
78
+ const maskedA = await userA.mask('Email alice@example.com', [
79
+ { type: 'EMAIL', value: 'alice@example.com' },
80
+ ]);
81
+ const maskedB = await userB.mask('Email bob@example.com', [
82
+ { type: 'EMAIL', value: 'bob@example.com' },
83
+ ]);
84
+
85
+ assert.equal(await userA.unmask(maskedA.maskedText), 'Email alice@example.com');
86
+ assert.equal(await userB.unmask(maskedB.maskedText), 'Email bob@example.com');
87
+ assert.equal(await userA.unmask(maskedB.maskedText), maskedB.maskedText);
88
+
89
+ const laterPass = new PiiTokenizer({ scopeId: 'tenant:userA:later', storage });
90
+ await laterPass.importTokens(maskedA.maskedText);
91
+ assert.equal(await laterPass.unmask(maskedA.maskedText), 'Email alice@example.com');
92
+
93
+ console.log('Live Redis PII vault test passed.');
94
+ } finally {
95
+ await redis.del(`${keyPrefix}:tokens`).catch(() => {});
96
+ await redis.del(`${keyPrefix}:scope:tenant:userA`).catch(() => {});
97
+ await redis.del(`${keyPrefix}:scope:tenant:userB`).catch(() => {});
98
+ await redis.del(`${keyPrefix}:scope:tenant:userA:later`).catch(() => {});
99
+ }
100
+
101
+ function encodeCommand(parts) {
102
+ return `*${parts.length}\r\n${parts.map((part) => {
103
+ const value = String(part);
104
+ return `$${Buffer.byteLength(value)}\r\n${value}\r\n`;
105
+ }).join('')}`;
106
+ }
107
+
108
+ function parseResp(buffer, offset) {
109
+ if (offset >= buffer.length) {
110
+ throw new Error('Incomplete RESP response');
111
+ }
112
+
113
+ const prefix = String.fromCharCode(buffer[offset]);
114
+ if (prefix === '+') return parseSimple(buffer, offset);
115
+ if (prefix === '-') {
116
+ const [message] = parseSimple(buffer, offset);
117
+ throw new Error(message);
118
+ }
119
+ if (prefix === ':') {
120
+ const [value, next] = parseSimple(buffer, offset);
121
+ return [Number(value), next];
122
+ }
123
+ if (prefix === '$') return parseBulk(buffer, offset);
124
+ if (prefix === '*') return parseArray(buffer, offset);
125
+ throw new Error(`Unsupported RESP prefix: ${prefix}`);
126
+ }
127
+
128
+ function parseSimple(buffer, offset) {
129
+ const end = buffer.indexOf('\r\n', offset);
130
+ if (end === -1) throw new Error('Incomplete RESP response');
131
+ return [buffer.toString('utf8', offset + 1, end), end + 2];
132
+ }
133
+
134
+ function parseBulk(buffer, offset) {
135
+ const [lengthText, valueStart] = parseSimple(buffer, offset);
136
+ const length = Number(lengthText);
137
+ if (length === -1) return [null, valueStart];
138
+ const valueEnd = valueStart + length;
139
+ if (buffer.length < valueEnd + 2) throw new Error('Incomplete RESP response');
140
+ return [buffer.toString('utf8', valueStart, valueEnd), valueEnd + 2];
141
+ }
142
+
143
+ function parseArray(buffer, offset) {
144
+ const [lengthText, start] = parseSimple(buffer, offset);
145
+ const values = [];
146
+ let next = start;
147
+ for (let i = 0; i < Number(lengthText); i++) {
148
+ const [value, newOffset] = parseResp(buffer, next);
149
+ values.push(value);
150
+ next = newOffset;
151
+ }
152
+ return [values, next];
153
+ }
@@ -0,0 +1,137 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ createPiiVaultStorage,
4
+ createRedisPiiVaultStorage,
5
+ InMemoryPiiVaultStorage,
6
+ } from '../dist/index.js';
7
+ import { PiiTokenizer } from '../dist/pii/tokenizer.js';
8
+
9
+ class FakeRedis {
10
+ hashes = new Map();
11
+ expirations = new Map();
12
+
13
+ async hGet(key, field) {
14
+ return this.hashes.get(key)?.[field] ?? null;
15
+ }
16
+
17
+ async hSet(key, field, value) {
18
+ const hash = this.hashes.get(key) ?? {};
19
+ hash[field] = value;
20
+ this.hashes.set(key, hash);
21
+ }
22
+
23
+ async hGetAll(key) {
24
+ return this.hashes.get(key) ?? {};
25
+ }
26
+
27
+ async expire(key, seconds) {
28
+ this.expirations.set(key, seconds);
29
+ }
30
+ }
31
+
32
+ const memory = new InMemoryPiiVaultStorage();
33
+ await memory.set('scope-a', '[[EMAIL_token_0]]', 'a@example.com');
34
+ assert.equal(await memory.get('scope-a', '[[EMAIL_token_0]]'), 'a@example.com');
35
+ assert.equal(await memory.get('scope-b', '[[EMAIL_token_0]]'), undefined);
36
+ assert.equal(await memory.getByToken('[[EMAIL_token_0]]'), 'a@example.com');
37
+
38
+ const custom = createPiiVaultStorage({
39
+ async get(scopeId, token) {
40
+ return scopeId === 'custom' && token === '[[EMAIL_custom_0]]' ? 'custom@example.com' : undefined;
41
+ },
42
+ async set() {},
43
+ async entries(scopeId) {
44
+ return scopeId === 'custom'
45
+ ? [{ token: '[[EMAIL_custom_0]]', value: 'custom@example.com' }]
46
+ : [];
47
+ },
48
+ });
49
+
50
+ const customTokenizer = new PiiTokenizer({ scopeId: 'custom', storage: custom });
51
+ assert.equal(await customTokenizer.unmask('Hi [[EMAIL_custom_0]]'), 'Hi custom@example.com');
52
+
53
+ const redis = new FakeRedis();
54
+ const redisStorage = createRedisPiiVaultStorage(redis, {
55
+ keyPrefix: 'test:pii',
56
+ ttlSeconds: 60,
57
+ });
58
+
59
+ const userATokenizer = new PiiTokenizer({ scopeId: 'tenant:userA', storage: redisStorage });
60
+ const userBTokenizer = new PiiTokenizer({ scopeId: 'tenant:userB', storage: redisStorage });
61
+
62
+ const userAMasked = await userATokenizer.mask('Email alice@example.com', [
63
+ { type: 'EMAIL', value: 'alice@example.com' },
64
+ ]);
65
+ const userBMasked = await userBTokenizer.mask('Email bob@example.com', [
66
+ { type: 'EMAIL', value: 'bob@example.com' },
67
+ ]);
68
+
69
+ assert.notEqual(userAMasked.maskedText, userBMasked.maskedText);
70
+ assert.equal(await userATokenizer.unmask(userAMasked.maskedText), 'Email alice@example.com');
71
+ assert.equal(await userBTokenizer.unmask(userBMasked.maskedText), 'Email bob@example.com');
72
+ assert.equal(await userATokenizer.unmask(userBMasked.maskedText), userBMasked.maskedText);
73
+
74
+ const laterPassTokenizer = new PiiTokenizer({ scopeId: 'tenant:userA:later', storage: redisStorage });
75
+ await laterPassTokenizer.importTokens(userAMasked.maskedText);
76
+ assert.equal(await laterPassTokenizer.unmask(userAMasked.maskedText), 'Email alice@example.com');
77
+
78
+ assert.equal(redis.expirations.get('test:pii:scope:tenant:userA'), 60);
79
+ assert.equal(redis.expirations.get('test:pii:tokens'), 60);
80
+
81
+ class FailingRedis extends FakeRedis {
82
+ failed = false;
83
+
84
+ fail() {
85
+ this.failed = true;
86
+ }
87
+
88
+ async hGet(key, field) {
89
+ if (this.failed) throw new Error('Redis unavailable');
90
+ return super.hGet(key, field);
91
+ }
92
+
93
+ async hSet(key, field, value) {
94
+ if (this.failed) throw new Error('Redis unavailable');
95
+ return super.hSet(key, field, value);
96
+ }
97
+
98
+ async hGetAll(key) {
99
+ if (this.failed) throw new Error('Redis unavailable');
100
+ return super.hGetAll(key);
101
+ }
102
+ }
103
+
104
+ const failingRedis = new FailingRedis();
105
+ const resilientStorage = createRedisPiiVaultStorage(failingRedis, { fallbackToMemory: true });
106
+ await resilientStorage.set('resilient-scope', '[[EMAIL_resilient_0]]', 'safe@example.com');
107
+ failingRedis.fail();
108
+ assert.equal(
109
+ await resilientStorage.get('resilient-scope', '[[EMAIL_resilient_0]]'),
110
+ 'safe@example.com'
111
+ );
112
+ assert.equal(await resilientStorage.getByToken('[[EMAIL_resilient_0]]'), 'safe@example.com');
113
+ assert.deepEqual(await resilientStorage.entries('resilient-scope'), [
114
+ { token: '[[EMAIL_resilient_0]]', value: 'safe@example.com' },
115
+ ]);
116
+ await resilientStorage.set('resilient-scope', '[[PHONE_resilient_1]]', '0400000000');
117
+ assert.equal(await resilientStorage.getByToken('[[PHONE_resilient_1]]'), '0400000000');
118
+
119
+ const strictRedis = new FailingRedis();
120
+ const strictStorage = createRedisPiiVaultStorage(strictRedis);
121
+ strictRedis.fail();
122
+ await assert.rejects(() => strictStorage.get('scope', 'token'), /Redis unavailable/);
123
+
124
+ assert.throws(
125
+ () => createRedisPiiVaultStorage(new FakeRedis(), { ttlSeconds: 0 }),
126
+ /positive integer/
127
+ );
128
+ assert.throws(
129
+ () =>
130
+ createRedisPiiVaultStorage(
131
+ { hGet: async () => null, hSet: async () => undefined, hGetAll: async () => ({}) },
132
+ { ttlSeconds: 60 }
133
+ ),
134
+ /requires an expire method/
135
+ );
136
+
137
+ console.log('PII vault storage tests passed.');
@@ -0,0 +1,21 @@
1
+ import { guard, createRedisPiiVaultStorage, type RedisPiiVaultClient } from '../src/index.js';
2
+
3
+ const redis: RedisPiiVaultClient = {
4
+ async hGet() {
5
+ return undefined;
6
+ },
7
+ async hSet() {},
8
+ async hGetAll() {
9
+ return {};
10
+ },
11
+ };
12
+
13
+ guard({
14
+ pii: {
15
+ reversible: true,
16
+ vault: {
17
+ storage: createRedisPiiVaultStorage(redis),
18
+ scopeId: (req: any, ctx: any) => ctx?.auth?.sessionId ?? req?.metadata?.requestId,
19
+ },
20
+ },
21
+ });