hazo_secure 0.5.0 → 1.0.1

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
@@ -5,9 +5,10 @@ Security & compliance primitives for Node.js / Next.js apps. Four independent su
5
5
  | Subpath | Purpose |
6
6
  |---|---|
7
7
  | `hazo_secure/fetch` | SSRF-safe `safeFetch()` — domain allowlist, private-CIDR blocker, redirect controls |
8
- | `hazo_secure/ratelimit` | Sliding-window + token-bucket limiter with pluggable store (in-memory default, Redis adapter) |
8
+ | `hazo_secure/ratelimit` | Sliding-window + token-bucket limiter with pluggable store (in-memory default, hazo_connect adapter) |
9
9
  | `hazo_secure/crypto` | AES-256-GCM field-level encryption with key versioning and AAD support |
10
10
  | `hazo_secure/gdpr` | Exporter / anonymiser registry + lifecycle orchestrator. Uses `hazo_jobs` for async export, `hazo_files` for ZIP delivery |
11
+ | `hazo_secure/csrf` | Double-submit CSRF protection for Next.js routes — token generation, cookie header, constant-time verification |
11
12
 
12
13
  ## Install
13
14
 
@@ -15,7 +16,7 @@ Security & compliance primitives for Node.js / Next.js apps. Four independent su
15
16
  npm install hazo_secure
16
17
  ```
17
18
 
18
- `hazo_logs` is a required peer dep. `hazo_jobs` and `hazo_files` are optional peers — only needed if you use `hazo_secure/gdpr`.
19
+ `hazo_logs` is a required peer dep. `hazo_connect`, `hazo_jobs`, and `hazo_files` are optional peers — `hazo_connect` is needed for `ConnectRateLimitStore`; `hazo_jobs` and `hazo_files` are only needed for `hazo_secure/gdpr`.
19
20
 
20
21
  ## Quick start
21
22
 
@@ -72,6 +73,90 @@ gdpr.registerExporter({
72
73
  });
73
74
  ```
74
75
 
76
+ ## New in 1.0.0
77
+
78
+ ### `hazo_secure/ratelimit` — ConnectRateLimitStore (token bucket)
79
+
80
+ ```ts
81
+ import { ConnectRateLimitStore } from "hazo_secure/ratelimit";
82
+
83
+ const store = new ConnectRateLimitStore({ getHazoConnect: () => db });
84
+ const limiter = createRateLimiter({ store, windowMs: 60_000, max: 30 });
85
+ ```
86
+
87
+ Stores token-bucket state in `hazo_rl_buckets` (see `migrations/001_hazo_rl_buckets.sql`). Run the migration before use. Requires `hazo_connect` peer.
88
+
89
+ ### `hazo_secure/fetch` — undici dispatcher + `validateConnectIp`
90
+
91
+ ```ts
92
+ import { safeFetch, createSecureDispatcher, validateConnectIp } from "hazo_secure/fetch";
93
+
94
+ // Use standalone to close the DNS-rebinding gap at socket creation time
95
+ const dispatcher = createSecureDispatcher();
96
+ const res = await safeFetch(url, { policy, deps: { fetchImpl: (u, i) => fetch(u, { ...i, dispatcher }) } });
97
+
98
+ // Or validate resolved IPs yourself
99
+ validateConnectIp("169.254.169.254"); // throws — blocked CIDR
100
+ ```
101
+
102
+ Node.js only — undici is a Node dep. Edge runtime gets pre-flight checks only.
103
+
104
+ ### `hazo_secure/crypto` — HttpKeyProvider
105
+
106
+ ```ts
107
+ import { HttpKeyProvider } from "hazo_secure/crypto";
108
+
109
+ const provider = new HttpKeyProvider({
110
+ endpoint: "https://kms.internal/keys",
111
+ headers: { Authorization: `Bearer ${token}` },
112
+ ttlMs: 30_000, // in-process cache TTL (default: 30 s)
113
+ });
114
+ provider.clearCache(); // force next fetch to re-fetch from endpoint
115
+ ```
116
+
117
+ Cloud-agnostic — works with any HTTPS key endpoint that returns `{ keyId, key: "<base64>" }`. Caches keys in-process to avoid latency on every encrypt/decrypt call.
118
+
119
+ ### `hazo_secure/gdpr` — registry enhancements
120
+
121
+ ```ts
122
+ import { createGdprRegistry, GdprRegistryError } from "hazo_secure/gdpr";
123
+
124
+ const gdpr = createGdprRegistry({ auditIntent: logAuditEntry });
125
+
126
+ // Validate registry completeness
127
+ const issues = await gdpr.validateRegistry({ requiredDomains: ["persons", "payments"] });
128
+
129
+ // Export user data (streams records from all registered exporters)
130
+ for await (const file of gdpr.exportUser({ userId })) {
131
+ console.log(file.filename, file.content);
132
+ }
133
+
134
+ // Dry-run export without side effects
135
+ const files = await gdpr.dryRunExport({ userId });
136
+
137
+ // Anonymise user across all registered domains
138
+ await gdpr.anonymiseUser({ userId });
139
+ ```
140
+
141
+ `GdprRegistryError` is a typed error class — catch and `instanceof`-check it.
142
+
143
+ ---
144
+
145
+ ## Runtime Compatibility
146
+
147
+ | Subpath | Edge runtime | Node.js (server) | Notes |
148
+ |---|---|---|---|
149
+ | `hazo_secure` | ✅ | ✅ | Types only, no runtime weight |
150
+ | `hazo_secure/csrf` | ✅ | ✅ | Web Crypto API only |
151
+ | `hazo_secure/ratelimit` | ✅ (MemoryRateLimitStore) | ✅ | ConnectRateLimitStore is Node-only (hazo_connect) |
152
+ | `hazo_secure/fetch` | ⚠️ pre-flight only | ✅ full protection | Edge: host/IP checks only; Node: + undici connect-time validation |
153
+ | `hazo_secure/crypto` | ❌ | ✅ | Uses `node:crypto` |
154
+ | `hazo_secure/gdpr` | ❌ | ✅ | Async iterables + optional hazo_connect |
155
+
156
+ **Node.js ≥ 18 required** for all Node-only subpaths.
157
+
158
+ ---
159
+
75
160
  ## Design
76
161
 
77
162
  Each subpath is independent — no cross-imports between `/fetch`, `/ratelimit`, `/crypto`, `/gdpr`. The `gdpr` module *consumes* the others when a project wires them together, but the package itself doesn't entangle them.
@@ -1,3 +1,34 @@
1
+ interface HttpKeyProviderOptions {
2
+ /**
3
+ * Base HTTPS endpoint for key material.
4
+ * - current(): GET {endpoint}/current → { keyId: string; key: string }
5
+ * - byId(id): GET {endpoint}/{id} → { keyId: string; key: string }
6
+ *
7
+ * `key` must be base64url-encoded 32-byte AES-256 key material.
8
+ */
9
+ endpoint: string;
10
+ /**
11
+ * Fetch implementation. Inject safeFetch from hazo_secure/fetch for SSRF
12
+ * protection on external KMS endpoints. Defaults to globalThis.fetch.
13
+ */
14
+ fetch?: typeof globalThis.fetch;
15
+ /** Bearer token added as Authorization header on every request. */
16
+ token?: string;
17
+ /** Additional headers added to every request. */
18
+ headers?: Record<string, string>;
19
+ }
20
+ declare class HttpKeyProvider implements KeyProvider {
21
+ #private;
22
+ constructor(opts: HttpKeyProviderOptions);
23
+ current(): Promise<{
24
+ keyId: string;
25
+ key: Uint8Array;
26
+ }>;
27
+ byId(keyId: string): Promise<Uint8Array>;
28
+ /** Clear in-process key cache. Call after rotating keys. */
29
+ clearCache(): void;
30
+ }
31
+
1
32
  /**
2
33
  * Source of symmetric keys for `encryptField` / `decryptField`. The current
3
34
  * key is used for encryption; `byId` resolves any key (current or rotated-out)
@@ -104,4 +135,4 @@ declare class StaticKeyProvider implements KeyProvider {
104
135
  * Convention: `<table>:<entityId>:<field>` — collision-free across tables. */
105
136
  declare function aadFor(table: string, entityId: string, field: string): string;
106
137
 
107
- export { CryptoError, type EncryptedField, EnvKeyProvider, type KeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField };
138
+ export { CryptoError, type EncryptedField, EnvKeyProvider, HttpKeyProvider, type HttpKeyProviderOptions, type KeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField };
@@ -1,5 +1,84 @@
1
1
  // src/crypto/index.ts
2
2
  import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
3
+
4
+ // src/crypto/provider-http.ts
5
+ var KEY_LEN = 32;
6
+ var HttpKeyProvider = class {
7
+ #endpoint;
8
+ #fetch;
9
+ #token;
10
+ #headers;
11
+ /** Cache for key material, keyed by keyId. */
12
+ #cache = /* @__PURE__ */ new Map();
13
+ /** Cached result of the most recent successful /current fetch. */
14
+ #currentCache = null;
15
+ constructor(opts) {
16
+ this.#endpoint = opts.endpoint.replace(/\/$/, "");
17
+ this.#fetch = opts.fetch ?? globalThis.fetch;
18
+ this.#token = opts.token;
19
+ this.#headers = opts.headers ?? {};
20
+ }
21
+ async current() {
22
+ if (this.#currentCache) return this.#currentCache;
23
+ const data = await this.#fetchSegment("current");
24
+ const key = this.#parseAndCacheKey(data);
25
+ this.#currentCache = { keyId: data.keyId, key };
26
+ return this.#currentCache;
27
+ }
28
+ async byId(keyId) {
29
+ const cached = this.#cache.get(keyId);
30
+ if (cached) return cached;
31
+ const data = await this.#fetchSegment(keyId);
32
+ return this.#parseAndCacheKey(data);
33
+ }
34
+ /** Clear in-process key cache. Call after rotating keys. */
35
+ clearCache() {
36
+ this.#cache.clear();
37
+ this.#currentCache = null;
38
+ }
39
+ #parseAndCacheKey(data) {
40
+ const buf = Buffer.from(data.key, "base64url");
41
+ if (buf.byteLength !== KEY_LEN) {
42
+ throw new CryptoError(
43
+ `HttpKeyProvider: key ${data.keyId} is ${buf.byteLength} bytes; AES-256-GCM needs ${KEY_LEN}`,
44
+ "invalid_key"
45
+ );
46
+ }
47
+ const key = new Uint8Array(buf);
48
+ this.#cache.set(data.keyId, key);
49
+ return key;
50
+ }
51
+ async #fetchSegment(segment) {
52
+ const url = `${this.#endpoint}/${segment}`;
53
+ const headers = { ...this.#headers };
54
+ if (this.#token) headers["Authorization"] = `Bearer ${this.#token}`;
55
+ let res;
56
+ try {
57
+ res = await this.#fetch(url, { headers });
58
+ } catch (err) {
59
+ throw new CryptoError(
60
+ `HttpKeyProvider: fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`,
61
+ "key_not_found"
62
+ );
63
+ }
64
+ if (!res.ok) {
65
+ throw new CryptoError(
66
+ `HttpKeyProvider: endpoint returned ${res.status} for ${url}`,
67
+ "key_not_found"
68
+ );
69
+ }
70
+ try {
71
+ return await res.json();
72
+ } catch {
73
+ throw new CryptoError(
74
+ `HttpKeyProvider: invalid JSON response from ${url}`,
75
+ "key_not_found"
76
+ );
77
+ }
78
+ }
79
+ };
80
+
81
+ // src/crypto/index.ts
3
82
  var CryptoError = class extends Error {
4
83
  constructor(message, code) {
5
84
  super(message);
@@ -11,7 +90,7 @@ var CryptoError = class extends Error {
11
90
  var ALG = "aes-256-gcm";
12
91
  var IV_LEN = 12;
13
92
  var TAG_LEN = 16;
14
- var KEY_LEN = 32;
93
+ var KEY_LEN2 = 32;
15
94
  function toB64(buf) {
16
95
  return Buffer.from(buf).toString("base64");
17
96
  }
@@ -20,9 +99,9 @@ function fromB64(s) {
20
99
  }
21
100
  async function encryptField(plaintext, opts) {
22
101
  const { keyId, key } = await opts.keys.current();
23
- if (key.byteLength !== KEY_LEN) {
102
+ if (key.byteLength !== KEY_LEN2) {
24
103
  throw new CryptoError(
25
- `Key for ${keyId} is ${key.byteLength} bytes; AES-256-GCM needs ${KEY_LEN}.`,
104
+ `Key for ${keyId} is ${key.byteLength} bytes; AES-256-GCM needs ${KEY_LEN2}.`,
26
105
  "invalid_key"
27
106
  );
28
107
  }
@@ -99,9 +178,9 @@ var EnvKeyProvider = class {
99
178
  throw new CryptoError(`Missing ${varName} env var`, "key_not_found");
100
179
  }
101
180
  const buf = Buffer.from(raw, "base64");
102
- if (buf.byteLength !== KEY_LEN) {
181
+ if (buf.byteLength !== KEY_LEN2) {
103
182
  throw new CryptoError(
104
- `${varName} decodes to ${buf.byteLength} bytes; AES-256-GCM needs ${KEY_LEN}`,
183
+ `${varName} decodes to ${buf.byteLength} bytes; AES-256-GCM needs ${KEY_LEN2}`,
105
184
  "invalid_key"
106
185
  );
107
186
  }
@@ -140,6 +219,7 @@ function aadFor(table, entityId, field) {
140
219
  export {
141
220
  CryptoError,
142
221
  EnvKeyProvider,
222
+ HttpKeyProvider,
143
223
  StaticKeyProvider,
144
224
  aadFor,
145
225
  decryptField,
@@ -1,3 +1,19 @@
1
+ import { Agent } from 'undici';
2
+
3
+ /**
4
+ * Validates a resolved IP at connect time. Throws if private/reserved.
5
+ * Exported so it can be unit-tested directly.
6
+ */
7
+ declare function validateConnectIp(ip: string): void;
8
+ /**
9
+ * Creates an undici Agent whose DNS lookup validates the resolved IP before
10
+ * the TCP socket is opened. This prevents DNS rebinding: even if the DNS
11
+ * record flips after the pre-flight check, the IP is re-validated here.
12
+ *
13
+ * Node 18+ ships undici as a built-in; we pin an explicit dep for stability.
14
+ */
15
+ declare function createSecureDispatcher(): Agent;
16
+
1
17
  interface SafeFetchPolicy {
2
18
  allowedHosts?: readonly string[];
3
19
  allowedHostPatterns?: readonly RegExp[];
@@ -21,4 +37,4 @@ declare class SafeFetchError extends Error {
21
37
  }
22
38
  declare function safeFetch(input: string | URL, options: SafeFetchOptions): Promise<Response>;
23
39
 
24
- export { type SafeFetchDeps, SafeFetchError, type SafeFetchErrorCode, type SafeFetchOptions, type SafeFetchPolicy, safeFetch };
40
+ export { type SafeFetchDeps, SafeFetchError, type SafeFetchErrorCode, type SafeFetchOptions, type SafeFetchPolicy, createSecureDispatcher, safeFetch, validateConnectIp };
@@ -1,5 +1,6 @@
1
1
  // src/fetch/index.ts
2
- import { lookup } from "dns/promises";
2
+ import { lookup as lookup2 } from "dns/promises";
3
+ import { fetch as undiciFetch } from "undici";
3
4
 
4
5
  // src/fetch/cidr.ts
5
6
  import { isIP } from "net";
@@ -70,7 +71,38 @@ function isPrivateIp(ip) {
70
71
  return false;
71
72
  }
72
73
 
74
+ // src/fetch/dispatcher.ts
75
+ import { Agent } from "undici";
76
+ import * as dns from "dns";
77
+ function validateConnectIp(ip) {
78
+ if (isPrivateIp(ip)) {
79
+ const err = new Error(
80
+ `SSRF: resolved IP ${ip} is a private/reserved address \u2014 blocked at connect time`
81
+ );
82
+ err.code = "SSRF_PRIVATE_IP";
83
+ throw err;
84
+ }
85
+ }
86
+ function createSecureDispatcher() {
87
+ return new Agent({
88
+ connect: {
89
+ lookup(hostname, _options, callback) {
90
+ dns.lookup(hostname, (err, address, family) => {
91
+ if (err) return callback(err, "", 4);
92
+ try {
93
+ validateConnectIp(address);
94
+ } catch (ssrfErr) {
95
+ return callback(ssrfErr, "", 4);
96
+ }
97
+ callback(null, address, family);
98
+ });
99
+ }
100
+ }
101
+ });
102
+ }
103
+
73
104
  // src/fetch/index.ts
105
+ var secureDispatcher = createSecureDispatcher();
74
106
  var SafeFetchError = class extends Error {
75
107
  constructor(code, message) {
76
108
  super(message);
@@ -83,7 +115,7 @@ var DEFAULT_TIMEOUT_MS = 1e4;
83
115
  var DEFAULT_MAX_REDIRECTS = 3;
84
116
  var DEFAULT_PROTOCOLS = ["https:", "http:"];
85
117
  async function defaultDnsLookup(host) {
86
- const records = await lookup(host, { all: true, verbatim: true });
118
+ const records = await lookup2(host, { all: true, verbatim: true });
87
119
  return records.map((r) => r.address);
88
120
  }
89
121
  function hostAllowed(host, policy) {
@@ -129,7 +161,8 @@ async function validateUrl(url, policy, deps) {
129
161
  }
130
162
  async function safeFetch(input, options) {
131
163
  const { policy, deps = {}, ...init } = options;
132
- const fetchImpl = deps.fetchImpl ?? fetch;
164
+ const fetchImpl = deps.fetchImpl ?? // eslint-disable-next-line @typescript-eslint/no-explicit-any
165
+ ((url, init2) => undiciFetch(url, { ...init2, dispatcher: secureDispatcher }));
133
166
  const maxRedirects = policy.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
134
167
  const timeoutMs = policy.timeoutMs ?? DEFAULT_TIMEOUT_MS;
135
168
  let currentUrl;
@@ -185,5 +218,7 @@ async function safeFetch(input, options) {
185
218
  }
186
219
  export {
187
220
  SafeFetchError,
188
- safeFetch
221
+ createSecureDispatcher,
222
+ safeFetch,
223
+ validateConnectIp
189
224
  };
@@ -13,12 +13,50 @@ interface DomainAnonymiser {
13
13
  domain: string;
14
14
  anonymise(ctx: ExporterContext): Promise<void>;
15
15
  }
16
+ type UserExportResult = Array<{
17
+ domain: string;
18
+ files: Array<{
19
+ filename: string;
20
+ content: Uint8Array | string;
21
+ }>;
22
+ }>;
23
+ type DryRunExportResult = Array<{
24
+ domain: string;
25
+ files: string[];
26
+ }>;
27
+ interface ValidateRegistryOptions {
28
+ /** Domain names that MUST each have both an exporter and an anonymiser registered. */
29
+ requiredDomains: string[];
30
+ }
31
+ /** Matches hazo_audit's auditIntent after partial application with an adapter. */
32
+ type AuditIntentFn = (name: string, callback: () => Promise<void>, opts?: {
33
+ subject_kind?: string;
34
+ subject_id?: string;
35
+ metadata?: Record<string, unknown>;
36
+ }) => Promise<void>;
37
+ interface GdprRegistryOptions {
38
+ /**
39
+ * Optional audit trail. Pass hazo_audit's auditIntent partially applied:
40
+ * createGdprRegistry({
41
+ * auditIntent: (name, cb, opts) => auditIntent(adapter, name, cb, opts),
42
+ * })
43
+ */
44
+ auditIntent?: AuditIntentFn;
45
+ }
46
+ declare class GdprRegistryError extends Error {
47
+ readonly code: 'incomplete_registry';
48
+ constructor(message: string, code: 'incomplete_registry');
49
+ }
16
50
  interface GdprRegistry {
17
51
  registerExporter(exporter: DomainExporter): void;
18
52
  registerAnonymiser(anonymiser: DomainAnonymiser): void;
19
53
  listExporters(): readonly DomainExporter[];
20
54
  listAnonymisers(): readonly DomainAnonymiser[];
55
+ exportUser(ctx: ExporterContext): Promise<UserExportResult>;
56
+ anonymiseUser(ctx: ExporterContext): Promise<void>;
57
+ validateRegistry(opts: ValidateRegistryOptions): void;
58
+ dryRunExport(ctx: ExporterContext): Promise<DryRunExportResult>;
21
59
  }
22
- declare function createGdprRegistry(): GdprRegistry;
60
+ declare function createGdprRegistry(opts?: GdprRegistryOptions): GdprRegistry;
23
61
 
24
- export { type DomainAnonymiser, type DomainExporter, type ExporterContext, type GdprRegistry, createGdprRegistry };
62
+ export { type AuditIntentFn, type DomainAnonymiser, type DomainExporter, type DryRunExportResult, type ExporterContext, type GdprRegistry, GdprRegistryError, type GdprRegistryOptions, type UserExportResult, type ValidateRegistryOptions, createGdprRegistry };
@@ -1,22 +1,102 @@
1
1
  // src/gdpr/index.ts
2
- function createGdprRegistry() {
2
+ var GdprRegistryError = class extends Error {
3
+ constructor(message, code) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = "GdprRegistryError";
7
+ }
8
+ code;
9
+ };
10
+ function createGdprRegistry(opts = {}) {
3
11
  const exporters = [];
4
12
  const anonymisers = [];
13
+ const { auditIntent } = opts;
14
+ async function exportUser(ctx) {
15
+ const run = async () => {
16
+ const result = [];
17
+ for (const exporter of exporters) {
18
+ const files = [];
19
+ for await (const item of exporter.export(ctx)) {
20
+ files.push(item);
21
+ }
22
+ result.push({ domain: exporter.domain, files });
23
+ }
24
+ return result;
25
+ };
26
+ if (auditIntent) {
27
+ let result;
28
+ await auditIntent(
29
+ "gdpr.export",
30
+ async () => {
31
+ result = await run();
32
+ },
33
+ { subject_kind: "user", subject_id: ctx.userId }
34
+ );
35
+ return result;
36
+ }
37
+ return run();
38
+ }
39
+ async function anonymiseUser(ctx) {
40
+ const run = async () => {
41
+ for (const anonymiser of anonymisers) {
42
+ await anonymiser.anonymise(ctx);
43
+ }
44
+ };
45
+ if (auditIntent) {
46
+ await auditIntent(
47
+ "gdpr.anonymise",
48
+ run,
49
+ { subject_kind: "user", subject_id: ctx.userId, metadata: { domainCount: anonymisers.length } }
50
+ );
51
+ } else {
52
+ await run();
53
+ }
54
+ }
55
+ function validateRegistry(options) {
56
+ const exporterDomains = new Set(exporters.map((e) => e.domain));
57
+ const anonymiserDomains = new Set(anonymisers.map((a) => a.domain));
58
+ const gaps = [];
59
+ for (const domain of options.requiredDomains) {
60
+ const missing = [];
61
+ if (!exporterDomains.has(domain)) missing.push("exporter");
62
+ if (!anonymiserDomains.has(domain)) missing.push("anonymiser");
63
+ if (missing.length > 0) gaps.push(` ${domain}: missing ${missing.join(", ")}`);
64
+ }
65
+ if (gaps.length > 0) {
66
+ throw new GdprRegistryError(
67
+ `GDPR registry incomplete:
68
+ ${gaps.join("\n")}`,
69
+ "incomplete_registry"
70
+ );
71
+ }
72
+ }
73
+ async function dryRunExport(ctx) {
74
+ const result = [];
75
+ for (const exporter of exporters) {
76
+ const files = [];
77
+ for await (const item of exporter.export(ctx)) {
78
+ files.push(item.filename);
79
+ }
80
+ result.push({ domain: exporter.domain, files });
81
+ }
82
+ return result;
83
+ }
5
84
  return {
6
- registerExporter(e) {
85
+ registerExporter: (e) => {
7
86
  exporters.push(e);
8
87
  },
9
- registerAnonymiser(a) {
88
+ registerAnonymiser: (a) => {
10
89
  anonymisers.push(a);
11
90
  },
12
- listExporters() {
13
- return exporters;
14
- },
15
- listAnonymisers() {
16
- return anonymisers;
17
- }
91
+ listExporters: () => exporters,
92
+ listAnonymisers: () => anonymisers,
93
+ exportUser,
94
+ anonymiseUser,
95
+ validateRegistry,
96
+ dryRunExport
18
97
  };
19
98
  }
20
99
  export {
100
+ GdprRegistryError,
21
101
  createGdprRegistry
22
102
  };
@@ -8,7 +8,7 @@ declare class MemoryRateLimitStore implements RateLimitStore {
8
8
  private sweepTimer;
9
9
  constructor(opts?: MemoryStoreOptions);
10
10
  get(key: string): Promise<RateLimitState | null>;
11
- increment(key: string, windowMs: number): Promise<RateLimitState>;
11
+ increment(key: string, windowMs: number, _max?: number): Promise<RateLimitState>;
12
12
  reset(key: string): Promise<void>;
13
13
  size(): number;
14
14
  sweep(): void;
@@ -16,6 +16,50 @@ declare class MemoryRateLimitStore implements RateLimitStore {
16
16
  }
17
17
  declare function getDefaultMemoryStore(): MemoryRateLimitStore;
18
18
 
19
+ type BucketRow = {
20
+ id: string;
21
+ tokens: number;
22
+ last_refill_at: string;
23
+ max_tokens: number;
24
+ refill_rate_per_ms: number;
25
+ };
26
+ type SvcLike = {
27
+ findOneBy(criteria: Record<string, unknown>): Promise<BucketRow | null>;
28
+ insert(data: Partial<BucketRow> | Partial<BucketRow>[]): Promise<BucketRow[]>;
29
+ updateById(id: unknown, patch: Partial<BucketRow>): Promise<BucketRow[]>;
30
+ deleteById(id: unknown): Promise<void>;
31
+ };
32
+ interface ConnectRateLimitStoreOptions {
33
+ /**
34
+ * Factory that resolves to a hazo_connect HazoConnectAdapter. Used in production.
35
+ * Call createCrudService from hazo_connect/server before passing.
36
+ */
37
+ getAdapter?: () => unknown | Promise<unknown>;
38
+ /** For testing: inject a CrudService stub directly. */
39
+ _svc?: () => SvcLike | Promise<SvcLike>;
40
+ /** Injectable clock. Defaults to Date.now. */
41
+ now?: () => number;
42
+ }
43
+ /**
44
+ * Rate-limit store backed by hazo_connect (PostgreSQL or SQLite).
45
+ * Uses a token bucket algorithm — no boundary burst, continuous token refill.
46
+ *
47
+ * Requires the hazo_rl_buckets table from migrations/001_hazo_rl_buckets.sql.
48
+ *
49
+ * Token bucket → RateLimitState mapping:
50
+ * count = max - tokensAfterConsumption
51
+ * count <= max → allowed (tokensAfter >= 0)
52
+ * count > max → denied (tokensAfter < 0)
53
+ */
54
+ declare class ConnectRateLimitStore implements RateLimitStore {
55
+ #private;
56
+ constructor(opts: ConnectRateLimitStoreOptions);
57
+ private svc;
58
+ get(key: string): Promise<RateLimitState | null>;
59
+ increment(key: string, windowMs: number, max?: number): Promise<RateLimitState>;
60
+ reset(key: string): Promise<void>;
61
+ }
62
+
19
63
  interface RateLimitState {
20
64
  count: number;
21
65
  resetAt: number;
@@ -28,7 +72,8 @@ interface RateLimitDecision {
28
72
  }
29
73
  interface RateLimitStore {
30
74
  get(key: string): Promise<RateLimitState | null>;
31
- increment(key: string, windowMs: number): Promise<RateLimitState>;
75
+ /** max is optional for backward compat; ConnectRateLimitStore requires it. */
76
+ increment(key: string, windowMs: number, max?: number): Promise<RateLimitState>;
32
77
  reset(key: string): Promise<void>;
33
78
  }
34
79
  interface RateLimiterOptions {
@@ -51,4 +96,4 @@ interface WithRateLimitOptions extends RateLimiterOptions {
51
96
  type AnyRouteHandler = (...args: any[]) => Promise<Response>;
52
97
  declare function withRateLimit<H extends AnyRouteHandler>(handler: H, opts: WithRateLimitOptions): H;
53
98
 
54
- export { MemoryRateLimitStore, type RateLimitDecision, type RateLimitState, type RateLimitStore, type RateLimiter, type RateLimiterOptions, type WithRateLimitOptions, checkRateLimit, createRateLimiter, defaultKeyResolver, getDefaultMemoryStore, withRateLimit };
99
+ export { ConnectRateLimitStore, type ConnectRateLimitStoreOptions, MemoryRateLimitStore, type RateLimitDecision, type RateLimitState, type RateLimitStore, type RateLimiter, type RateLimiterOptions, type WithRateLimitOptions, checkRateLimit, createRateLimiter, defaultKeyResolver, getDefaultMemoryStore, withRateLimit };
@@ -21,7 +21,7 @@ var MemoryRateLimitStore = class {
21
21
  }
22
22
  return { ...e.state };
23
23
  }
24
- async increment(key, windowMs) {
24
+ async increment(key, windowMs, _max) {
25
25
  const t = this.now();
26
26
  const existing = this.map.get(key);
27
27
  if (!existing || existing.expiresAt <= t) {
@@ -57,6 +57,85 @@ function getDefaultMemoryStore() {
57
57
  return defaultStore;
58
58
  }
59
59
 
60
+ // src/ratelimit/store-connect.ts
61
+ var ConnectRateLimitStore = class {
62
+ #getSvc;
63
+ #now;
64
+ constructor(opts) {
65
+ if (opts._svc) {
66
+ this.#getSvc = opts._svc;
67
+ } else if (opts.getAdapter) {
68
+ const getAdapter = opts.getAdapter;
69
+ this.#getSvc = async () => {
70
+ const { createCrudService } = await import("hazo_connect/server");
71
+ const adapter = await getAdapter();
72
+ return createCrudService(adapter, "hazo_rl_buckets", {
73
+ autoId: { enabled: false, column: "id" }
74
+ });
75
+ };
76
+ } else {
77
+ throw new Error("ConnectRateLimitStore: provide getAdapter or _svc");
78
+ }
79
+ this.#now = opts.now ?? Date.now;
80
+ }
81
+ async svc() {
82
+ return this.#getSvc();
83
+ }
84
+ async get(key) {
85
+ const svc = await this.svc();
86
+ const row = await svc.findOneBy({ id: key });
87
+ if (!row) return null;
88
+ const now = this.#now();
89
+ const elapsedMs = now - new Date(row.last_refill_at).getTime();
90
+ const tokens = Math.min(row.max_tokens, row.tokens + elapsedMs * row.refill_rate_per_ms);
91
+ const storedTokens = Math.max(0, tokens);
92
+ const resetAt = now + Math.ceil(Math.max(0, 1 - storedTokens) / row.refill_rate_per_ms);
93
+ return { count: Math.max(0, row.max_tokens - tokens), resetAt };
94
+ }
95
+ async increment(key, windowMs, max) {
96
+ if (max === void 0) {
97
+ throw new Error(
98
+ "ConnectRateLimitStore requires max in increment(). Ensure createRateLimiter / checkRateLimit receives { max }."
99
+ );
100
+ }
101
+ const svc = await this.svc();
102
+ const now = this.#now();
103
+ const refillRatePerMs = max / windowMs;
104
+ const row = await svc.findOneBy({ id: key });
105
+ let tokensAfter;
106
+ if (!row) {
107
+ tokensAfter = max - 1;
108
+ await svc.insert({
109
+ id: key,
110
+ tokens: Math.max(0, tokensAfter),
111
+ last_refill_at: new Date(now).toISOString(),
112
+ max_tokens: max,
113
+ refill_rate_per_ms: refillRatePerMs
114
+ });
115
+ } else {
116
+ const elapsedMs = now - new Date(row.last_refill_at).getTime();
117
+ const refilledTokens = Math.min(max, row.tokens + elapsedMs * refillRatePerMs);
118
+ tokensAfter = refilledTokens - 1;
119
+ await svc.updateById(key, {
120
+ tokens: Math.max(0, tokensAfter),
121
+ last_refill_at: new Date(now).toISOString(),
122
+ max_tokens: max,
123
+ refill_rate_per_ms: refillRatePerMs
124
+ });
125
+ }
126
+ const count = max - tokensAfter;
127
+ const storedTokens = Math.max(0, tokensAfter);
128
+ const refillNeeded = Math.max(0, 1 - storedTokens);
129
+ const resetAt = now + Math.ceil(refillNeeded / refillRatePerMs);
130
+ return { count, resetAt };
131
+ }
132
+ async reset(key) {
133
+ const svc = await this.svc();
134
+ const row = await svc.findOneBy({ id: key });
135
+ if (row) await svc.deleteById(key);
136
+ }
137
+ };
138
+
60
139
  // src/ratelimit/index.ts
61
140
  function defaultKeyResolver(req) {
62
141
  const headers = req.headers;
@@ -73,7 +152,7 @@ function createRateLimiter(opts) {
73
152
  const keyResolver = opts.keyResolver ?? defaultKeyResolver;
74
153
  const now = opts.now ?? Date.now;
75
154
  async function checkKey(key) {
76
- const state = await store.increment(key, opts.windowMs);
155
+ const state = await store.increment(key, opts.windowMs, opts.max);
77
156
  const allowed = state.count <= opts.max;
78
157
  const remaining = Math.max(0, opts.max - state.count);
79
158
  const retryAfterSeconds = Math.max(0, Math.ceil((state.resetAt - now()) / 1e3));
@@ -113,6 +192,7 @@ function withRateLimit(handler, opts) {
113
192
  return fn;
114
193
  }
115
194
  export {
195
+ ConnectRateLimitStore,
116
196
  MemoryRateLimitStore,
117
197
  checkRateLimit,
118
198
  createRateLimiter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_secure",
3
- "version": "0.5.0",
3
+ "version": "1.0.1",
4
4
  "description": "Security & compliance primitives — SSRF-safe fetch, rate limiting, field-level encryption, GDPR orchestration. Four subpath modules, pick the ones you need.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,14 +54,25 @@
54
54
  "install:test-app": "cd test-app && npm install",
55
55
  "prepublishOnly": "npm run build"
56
56
  },
57
+ "dependencies": {
58
+ "undici": "^7.3.0"
59
+ },
57
60
  "peerDependencies": {
58
61
  "hazo_logs": "^1.1.0",
62
+ "hazo_connect": "^2.6.0",
59
63
  "hazo_jobs": "^0.7.0",
60
64
  "hazo_files": "^1.4.7"
61
65
  },
62
66
  "peerDependenciesMeta": {
63
- "hazo_jobs": { "optional": true },
64
- "hazo_files": { "optional": true }
67
+ "hazo_connect": {
68
+ "optional": true
69
+ },
70
+ "hazo_jobs": {
71
+ "optional": true
72
+ },
73
+ "hazo_files": {
74
+ "optional": true
75
+ }
65
76
  },
66
77
  "devDependencies": {
67
78
  "@types/jest": "^29.0.0",