hazo_secure 0.5.0 → 1.1.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/CHANGE_LOG.md +171 -0
- package/README.md +87 -2
- package/SETUP_CHECKLIST.md +68 -0
- package/config/hazo_secure_config.ini.sample +71 -0
- package/db_setup_postgres.sql +20 -0
- package/db_setup_sqlite.sql +18 -0
- package/dist/crypto/index.d.ts +44 -4
- package/dist/crypto/index.js +94 -9
- package/dist/csrf/index.d.ts +10 -1
- package/dist/csrf/index.js +9 -4
- package/dist/fetch/index.d.ts +29 -2
- package/dist/fetch/index.js +69 -8
- package/dist/gdpr/index.d.ts +49 -2
- package/dist/gdpr/index.js +94 -9
- package/dist/index.d.ts +40 -7
- package/dist/index.js +38 -9
- package/dist/ratelimit/index.d.ts +55 -3
- package/dist/ratelimit/index.js +98 -2
- package/migrations/001_hazo_rl_buckets.sql +26 -0
- package/package.json +45 -16
package/dist/csrf/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { HazoError } from 'hazo_core';
|
|
2
|
+
|
|
1
3
|
interface CsrfPolicy {
|
|
2
4
|
cookieName?: string;
|
|
3
5
|
headerName?: string;
|
|
@@ -14,7 +16,14 @@ interface CsrfPolicy {
|
|
|
14
16
|
};
|
|
15
17
|
}
|
|
16
18
|
type CsrfErrorCode = "csrf_cookie_missing" | "csrf_header_missing" | "csrf_mismatch" | "csrf_malformed";
|
|
17
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Typed error raised by `/csrf`. Extends `HazoError` so it participates in
|
|
21
|
+
* the workspace's correlation-ID auto-attach + envelope shape, while
|
|
22
|
+
* preserving the legacy `(code, message)` constructor + `err.code` short
|
|
23
|
+
* discriminator that 1.0.x consumers already check. All CSRF rejections
|
|
24
|
+
* map to HTTP 403.
|
|
25
|
+
*/
|
|
26
|
+
declare class CsrfError extends HazoError {
|
|
18
27
|
readonly code: CsrfErrorCode;
|
|
19
28
|
constructor(code: CsrfErrorCode, message: string);
|
|
20
29
|
}
|
package/dist/csrf/index.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
// src/csrf/index.ts
|
|
2
|
+
import { HazoError } from "hazo_core";
|
|
2
3
|
var DEFAULT_COOKIE_NAME = "hs_csrf";
|
|
3
4
|
var DEFAULT_HEADER_NAME = "x-csrf-token";
|
|
4
5
|
var DEFAULT_TOKEN_LENGTH = 32;
|
|
5
6
|
var DEFAULT_PROTECTED = ["POST", "PUT", "PATCH", "DELETE"];
|
|
6
|
-
var CsrfError = class extends
|
|
7
|
+
var CsrfError = class extends HazoError {
|
|
7
8
|
constructor(code, message) {
|
|
8
|
-
super(
|
|
9
|
-
|
|
9
|
+
super({
|
|
10
|
+
code,
|
|
11
|
+
pkg: "hazo_secure",
|
|
12
|
+
message,
|
|
13
|
+
httpStatus: 403,
|
|
14
|
+
retryable: false
|
|
15
|
+
});
|
|
10
16
|
this.name = "CsrfError";
|
|
11
17
|
}
|
|
12
|
-
code;
|
|
13
18
|
};
|
|
14
19
|
function resolved(policy) {
|
|
15
20
|
return {
|
package/dist/fetch/index.d.ts
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
import { HazoError } from 'hazo_core';
|
|
2
|
+
import { Agent } from 'undici';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates a resolved IP at connect time. Throws `SafeFetchError("private_ip_blocked")`
|
|
6
|
+
* if private/reserved. Exported so it can be unit-tested directly.
|
|
7
|
+
*
|
|
8
|
+
* Carries the legacy `SSRF_PRIVATE_IP` ErrnoException-style code on the error
|
|
9
|
+
* for older consumers that grepped for it. New code should branch on the
|
|
10
|
+
* SafeFetchError discriminator instead.
|
|
11
|
+
*/
|
|
12
|
+
declare function validateConnectIp(ip: string): void;
|
|
13
|
+
/**
|
|
14
|
+
* Creates an undici Agent whose DNS lookup validates the resolved IP before
|
|
15
|
+
* the TCP socket is opened. This prevents DNS rebinding: even if the DNS
|
|
16
|
+
* record flips after the pre-flight check, the IP is re-validated here.
|
|
17
|
+
*
|
|
18
|
+
* Node 18+ ships undici as a built-in; we pin an explicit dep for stability.
|
|
19
|
+
*/
|
|
20
|
+
declare function createSecureDispatcher(): Agent;
|
|
21
|
+
|
|
1
22
|
interface SafeFetchPolicy {
|
|
2
23
|
allowedHosts?: readonly string[];
|
|
3
24
|
allowedHostPatterns?: readonly RegExp[];
|
|
@@ -15,10 +36,16 @@ interface SafeFetchOptions extends Omit<RequestInit, "redirect"> {
|
|
|
15
36
|
deps?: SafeFetchDeps;
|
|
16
37
|
}
|
|
17
38
|
type SafeFetchErrorCode = "invalid_url" | "protocol_not_allowed" | "host_not_allowed" | "dns_failed" | "private_ip_blocked" | "redirect_limit" | "redirect_loop" | "timeout";
|
|
18
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Typed error raised by `safeFetch`. Extends `HazoError` so it participates
|
|
41
|
+
* in the workspace's correlation-ID auto-attach + envelope JSON shape, while
|
|
42
|
+
* preserving the legacy `(code, message)` constructor + `err.code` short
|
|
43
|
+
* discriminator that 1.0.x consumers already check.
|
|
44
|
+
*/
|
|
45
|
+
declare class SafeFetchError extends HazoError {
|
|
19
46
|
readonly code: SafeFetchErrorCode;
|
|
20
47
|
constructor(code: SafeFetchErrorCode, message: string);
|
|
21
48
|
}
|
|
22
49
|
declare function safeFetch(input: string | URL, options: SafeFetchOptions): Promise<Response>;
|
|
23
50
|
|
|
24
|
-
export { type SafeFetchDeps, SafeFetchError, type SafeFetchErrorCode, type SafeFetchOptions, type SafeFetchPolicy, safeFetch };
|
|
51
|
+
export { type SafeFetchDeps, SafeFetchError, type SafeFetchErrorCode, type SafeFetchOptions, type SafeFetchPolicy, createSecureDispatcher, safeFetch, validateConnectIp };
|
package/dist/fetch/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
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";
|
|
4
|
+
import { HazoError, REQUEST_ID_HEADER, getCorrelationId } from "hazo_core";
|
|
3
5
|
|
|
4
6
|
// src/fetch/cidr.ts
|
|
5
7
|
import { isIP } from "net";
|
|
@@ -70,20 +72,68 @@ function isPrivateIp(ip) {
|
|
|
70
72
|
return false;
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
// src/fetch/dispatcher.ts
|
|
76
|
+
import { Agent } from "undici";
|
|
77
|
+
import * as dns from "dns";
|
|
78
|
+
function validateConnectIp(ip) {
|
|
79
|
+
if (isPrivateIp(ip)) {
|
|
80
|
+
const err = new SafeFetchError(
|
|
81
|
+
"private_ip_blocked",
|
|
82
|
+
`SSRF: resolved IP ${ip} is a private/reserved address \u2014 blocked at connect time`
|
|
83
|
+
);
|
|
84
|
+
err.code = "private_ip_blocked";
|
|
85
|
+
err.ssrfCode = "SSRF_PRIVATE_IP";
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function createSecureDispatcher() {
|
|
90
|
+
return new Agent({
|
|
91
|
+
connect: {
|
|
92
|
+
lookup(hostname, _options, callback) {
|
|
93
|
+
dns.lookup(hostname, (err, address, family) => {
|
|
94
|
+
if (err) return callback(err, "", 4);
|
|
95
|
+
try {
|
|
96
|
+
validateConnectIp(address);
|
|
97
|
+
} catch (ssrfErr) {
|
|
98
|
+
return callback(ssrfErr, "", 4);
|
|
99
|
+
}
|
|
100
|
+
callback(null, address, family);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
73
107
|
// src/fetch/index.ts
|
|
74
|
-
var
|
|
108
|
+
var secureDispatcher = createSecureDispatcher();
|
|
109
|
+
var SAFE_FETCH_STATUS_MAP = {
|
|
110
|
+
invalid_url: { httpStatus: 400, retryable: false },
|
|
111
|
+
protocol_not_allowed: { httpStatus: 400, retryable: false },
|
|
112
|
+
host_not_allowed: { httpStatus: 403, retryable: false },
|
|
113
|
+
dns_failed: { httpStatus: 502, retryable: true },
|
|
114
|
+
private_ip_blocked: { httpStatus: 403, retryable: false },
|
|
115
|
+
redirect_limit: { httpStatus: 502, retryable: false },
|
|
116
|
+
redirect_loop: { httpStatus: 502, retryable: false },
|
|
117
|
+
timeout: { httpStatus: 504, retryable: true }
|
|
118
|
+
};
|
|
119
|
+
var SafeFetchError = class extends HazoError {
|
|
75
120
|
constructor(code, message) {
|
|
76
|
-
|
|
77
|
-
|
|
121
|
+
const { httpStatus, retryable } = SAFE_FETCH_STATUS_MAP[code];
|
|
122
|
+
super({
|
|
123
|
+
code,
|
|
124
|
+
pkg: "hazo_secure",
|
|
125
|
+
message,
|
|
126
|
+
httpStatus,
|
|
127
|
+
retryable
|
|
128
|
+
});
|
|
78
129
|
this.name = "SafeFetchError";
|
|
79
130
|
}
|
|
80
|
-
code;
|
|
81
131
|
};
|
|
82
132
|
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
83
133
|
var DEFAULT_MAX_REDIRECTS = 3;
|
|
84
134
|
var DEFAULT_PROTOCOLS = ["https:", "http:"];
|
|
85
135
|
async function defaultDnsLookup(host) {
|
|
86
|
-
const records = await
|
|
136
|
+
const records = await lookup2(host, { all: true, verbatim: true });
|
|
87
137
|
return records.map((r) => r.address);
|
|
88
138
|
}
|
|
89
139
|
function hostAllowed(host, policy) {
|
|
@@ -129,7 +179,8 @@ async function validateUrl(url, policy, deps) {
|
|
|
129
179
|
}
|
|
130
180
|
async function safeFetch(input, options) {
|
|
131
181
|
const { policy, deps = {}, ...init } = options;
|
|
132
|
-
const fetchImpl = deps.fetchImpl ??
|
|
182
|
+
const fetchImpl = deps.fetchImpl ?? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
183
|
+
((url, init2) => undiciFetch(url, { ...init2, dispatcher: secureDispatcher }));
|
|
133
184
|
const maxRedirects = policy.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
134
185
|
const timeoutMs = policy.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
135
186
|
let currentUrl;
|
|
@@ -138,6 +189,14 @@ async function safeFetch(input, options) {
|
|
|
138
189
|
} catch {
|
|
139
190
|
throw new SafeFetchError("invalid_url", `Could not parse URL: ${String(input)}`);
|
|
140
191
|
}
|
|
192
|
+
const correlationId = getCorrelationId();
|
|
193
|
+
if (correlationId) {
|
|
194
|
+
const existingHeaders = new Headers(init.headers);
|
|
195
|
+
if (!existingHeaders.has(REQUEST_ID_HEADER)) {
|
|
196
|
+
existingHeaders.set(REQUEST_ID_HEADER, correlationId);
|
|
197
|
+
init.headers = existingHeaders;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
141
200
|
const visited = /* @__PURE__ */ new Set();
|
|
142
201
|
let hopsRemaining = maxRedirects;
|
|
143
202
|
while (true) {
|
|
@@ -185,5 +244,7 @@ async function safeFetch(input, options) {
|
|
|
185
244
|
}
|
|
186
245
|
export {
|
|
187
246
|
SafeFetchError,
|
|
188
|
-
|
|
247
|
+
createSecureDispatcher,
|
|
248
|
+
safeFetch,
|
|
249
|
+
validateConnectIp
|
|
189
250
|
};
|
package/dist/gdpr/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { HazoError } from 'hazo_core';
|
|
2
|
+
|
|
1
3
|
interface ExporterContext {
|
|
2
4
|
userId: string;
|
|
3
5
|
locale?: string;
|
|
@@ -13,12 +15,57 @@ interface DomainAnonymiser {
|
|
|
13
15
|
domain: string;
|
|
14
16
|
anonymise(ctx: ExporterContext): Promise<void>;
|
|
15
17
|
}
|
|
18
|
+
type UserExportResult = Array<{
|
|
19
|
+
domain: string;
|
|
20
|
+
files: Array<{
|
|
21
|
+
filename: string;
|
|
22
|
+
content: Uint8Array | string;
|
|
23
|
+
}>;
|
|
24
|
+
}>;
|
|
25
|
+
type DryRunExportResult = Array<{
|
|
26
|
+
domain: string;
|
|
27
|
+
files: string[];
|
|
28
|
+
}>;
|
|
29
|
+
interface ValidateRegistryOptions {
|
|
30
|
+
/** Domain names that MUST each have both an exporter and an anonymiser registered. */
|
|
31
|
+
requiredDomains: string[];
|
|
32
|
+
}
|
|
33
|
+
/** Matches hazo_audit's auditIntent after partial application with an adapter. */
|
|
34
|
+
type AuditIntentFn = (name: string, callback: () => Promise<void>, opts?: {
|
|
35
|
+
subject_kind?: string;
|
|
36
|
+
subject_id?: string;
|
|
37
|
+
metadata?: Record<string, unknown>;
|
|
38
|
+
}) => Promise<void>;
|
|
39
|
+
interface GdprRegistryOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Optional audit trail. Pass hazo_audit's auditIntent partially applied:
|
|
42
|
+
* createGdprRegistry({
|
|
43
|
+
* auditIntent: (name, cb, opts) => auditIntent(adapter, name, cb, opts),
|
|
44
|
+
* })
|
|
45
|
+
*/
|
|
46
|
+
auditIntent?: AuditIntentFn;
|
|
47
|
+
}
|
|
48
|
+
type GdprRegistryErrorCode = 'incomplete_registry';
|
|
49
|
+
/**
|
|
50
|
+
* Typed error raised by `/gdpr`. Extends `HazoError` so registry-incomplete
|
|
51
|
+
* gaps surface through the workspace's correlation-ID + envelope shape, while
|
|
52
|
+
* preserving the legacy `(message, code)` constructor + `err.code` short
|
|
53
|
+
* discriminator that 1.0.x consumers already check.
|
|
54
|
+
*/
|
|
55
|
+
declare class GdprRegistryError extends HazoError {
|
|
56
|
+
readonly code: GdprRegistryErrorCode;
|
|
57
|
+
constructor(message: string, code: GdprRegistryErrorCode);
|
|
58
|
+
}
|
|
16
59
|
interface GdprRegistry {
|
|
17
60
|
registerExporter(exporter: DomainExporter): void;
|
|
18
61
|
registerAnonymiser(anonymiser: DomainAnonymiser): void;
|
|
19
62
|
listExporters(): readonly DomainExporter[];
|
|
20
63
|
listAnonymisers(): readonly DomainAnonymiser[];
|
|
64
|
+
exportUser(ctx: ExporterContext): Promise<UserExportResult>;
|
|
65
|
+
anonymiseUser(ctx: ExporterContext): Promise<void>;
|
|
66
|
+
validateRegistry(opts: ValidateRegistryOptions): void;
|
|
67
|
+
dryRunExport(ctx: ExporterContext): Promise<DryRunExportResult>;
|
|
21
68
|
}
|
|
22
|
-
declare function createGdprRegistry(): GdprRegistry;
|
|
69
|
+
declare function createGdprRegistry(opts?: GdprRegistryOptions): GdprRegistry;
|
|
23
70
|
|
|
24
|
-
export { type DomainAnonymiser, type DomainExporter, type ExporterContext, type GdprRegistry, createGdprRegistry };
|
|
71
|
+
export { type AuditIntentFn, type DomainAnonymiser, type DomainExporter, type DryRunExportResult, type ExporterContext, type GdprRegistry, GdprRegistryError, type GdprRegistryErrorCode, type GdprRegistryOptions, type UserExportResult, type ValidateRegistryOptions, createGdprRegistry };
|
package/dist/gdpr/index.js
CHANGED
|
@@ -1,22 +1,107 @@
|
|
|
1
1
|
// src/gdpr/index.ts
|
|
2
|
-
|
|
2
|
+
import { HazoError } from "hazo_core";
|
|
3
|
+
var GdprRegistryError = class extends HazoError {
|
|
4
|
+
constructor(message, code) {
|
|
5
|
+
super({
|
|
6
|
+
code,
|
|
7
|
+
pkg: "hazo_secure",
|
|
8
|
+
message,
|
|
9
|
+
httpStatus: 500,
|
|
10
|
+
retryable: false
|
|
11
|
+
});
|
|
12
|
+
this.name = "GdprRegistryError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
function createGdprRegistry(opts = {}) {
|
|
3
16
|
const exporters = [];
|
|
4
17
|
const anonymisers = [];
|
|
18
|
+
const { auditIntent } = opts;
|
|
19
|
+
async function exportUser(ctx) {
|
|
20
|
+
const run = async () => {
|
|
21
|
+
const result = [];
|
|
22
|
+
for (const exporter of exporters) {
|
|
23
|
+
const files = [];
|
|
24
|
+
for await (const item of exporter.export(ctx)) {
|
|
25
|
+
files.push(item);
|
|
26
|
+
}
|
|
27
|
+
result.push({ domain: exporter.domain, files });
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
if (auditIntent) {
|
|
32
|
+
let result;
|
|
33
|
+
await auditIntent(
|
|
34
|
+
"gdpr.export",
|
|
35
|
+
async () => {
|
|
36
|
+
result = await run();
|
|
37
|
+
},
|
|
38
|
+
{ subject_kind: "user", subject_id: ctx.userId }
|
|
39
|
+
);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
return run();
|
|
43
|
+
}
|
|
44
|
+
async function anonymiseUser(ctx) {
|
|
45
|
+
const run = async () => {
|
|
46
|
+
for (const anonymiser of anonymisers) {
|
|
47
|
+
await anonymiser.anonymise(ctx);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
if (auditIntent) {
|
|
51
|
+
await auditIntent(
|
|
52
|
+
"gdpr.anonymise",
|
|
53
|
+
run,
|
|
54
|
+
{ subject_kind: "user", subject_id: ctx.userId, metadata: { domainCount: anonymisers.length } }
|
|
55
|
+
);
|
|
56
|
+
} else {
|
|
57
|
+
await run();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function validateRegistry(options) {
|
|
61
|
+
const exporterDomains = new Set(exporters.map((e) => e.domain));
|
|
62
|
+
const anonymiserDomains = new Set(anonymisers.map((a) => a.domain));
|
|
63
|
+
const gaps = [];
|
|
64
|
+
for (const domain of options.requiredDomains) {
|
|
65
|
+
const missing = [];
|
|
66
|
+
if (!exporterDomains.has(domain)) missing.push("exporter");
|
|
67
|
+
if (!anonymiserDomains.has(domain)) missing.push("anonymiser");
|
|
68
|
+
if (missing.length > 0) gaps.push(` ${domain}: missing ${missing.join(", ")}`);
|
|
69
|
+
}
|
|
70
|
+
if (gaps.length > 0) {
|
|
71
|
+
throw new GdprRegistryError(
|
|
72
|
+
`GDPR registry incomplete:
|
|
73
|
+
${gaps.join("\n")}`,
|
|
74
|
+
"incomplete_registry"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function dryRunExport(ctx) {
|
|
79
|
+
const result = [];
|
|
80
|
+
for (const exporter of exporters) {
|
|
81
|
+
const files = [];
|
|
82
|
+
for await (const item of exporter.export(ctx)) {
|
|
83
|
+
files.push(item.filename);
|
|
84
|
+
}
|
|
85
|
+
result.push({ domain: exporter.domain, files });
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
5
89
|
return {
|
|
6
|
-
registerExporter(e) {
|
|
90
|
+
registerExporter: (e) => {
|
|
7
91
|
exporters.push(e);
|
|
8
92
|
},
|
|
9
|
-
registerAnonymiser(a) {
|
|
93
|
+
registerAnonymiser: (a) => {
|
|
10
94
|
anonymisers.push(a);
|
|
11
95
|
},
|
|
12
|
-
listExporters()
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
96
|
+
listExporters: () => exporters,
|
|
97
|
+
listAnonymisers: () => anonymisers,
|
|
98
|
+
exportUser,
|
|
99
|
+
anonymiseUser,
|
|
100
|
+
validateRegistry,
|
|
101
|
+
dryRunExport
|
|
18
102
|
};
|
|
19
103
|
}
|
|
20
104
|
export {
|
|
105
|
+
GdprRegistryError,
|
|
21
106
|
createGdprRegistry
|
|
22
107
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger utility for hazo_secure.
|
|
3
|
+
*
|
|
4
|
+
* Default: delegates to hazo_core's createLogger('hazo_secure'), which
|
|
5
|
+
* auto-injects correlationId, env, and writes structured JSON via hazo_logs.
|
|
6
|
+
* Consumers can still override with set_logger() — useful for tests, or for
|
|
7
|
+
* apps that want to route hazo_secure logs through their own sink.
|
|
8
|
+
*/
|
|
1
9
|
interface Logger {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
warn(
|
|
5
|
-
error(
|
|
10
|
+
info: (message: string, data?: Record<string, unknown>) => void;
|
|
11
|
+
debug: (message: string, data?: Record<string, unknown>) => void;
|
|
12
|
+
warn: (message: string, data?: Record<string, unknown>) => void;
|
|
13
|
+
error: (message: string, data?: Record<string, unknown>) => void;
|
|
6
14
|
}
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Set the logger instance used throughout hazo_secure.
|
|
17
|
+
* Pass `undefined` to reset to the hazo_core default.
|
|
18
|
+
*/
|
|
19
|
+
declare function set_logger(logger: Logger | undefined): void;
|
|
20
|
+
/**
|
|
21
|
+
* Get the current logger instance — defaults to hazo_core's createLogger
|
|
22
|
+
* on first access if no consumer override has been set.
|
|
23
|
+
*/
|
|
24
|
+
declare function get_logger(): Logger;
|
|
25
|
+
/**
|
|
26
|
+
* Re-exported for backwards compatibility with hazo_secure < 1.1.0, which
|
|
27
|
+
* shipped a `NoopLogger` constant for consumers that wanted to silence
|
|
28
|
+
* package logs. Prefer `set_logger(noop_logger)` going forward.
|
|
29
|
+
*/
|
|
30
|
+
declare const noop_logger: Logger;
|
|
9
31
|
|
|
10
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Root entry — shared types only. Functional surface lives in the subpath
|
|
34
|
+
* modules (`hazo_secure/fetch`, `/ratelimit`, `/crypto`, `/gdpr`, `/csrf`).
|
|
35
|
+
*
|
|
36
|
+
* Logger helpers re-exported here so consumers can override the in-package
|
|
37
|
+
* logger without reaching into a subpath: `import { set_logger } from
|
|
38
|
+
* 'hazo_secure'`.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
declare const PACKAGE_VERSION = "1.1.0";
|
|
42
|
+
|
|
43
|
+
export { type Logger, noop_logger as NoopLogger, PACKAGE_VERSION, get_logger, noop_logger, set_logger };
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// src/utils/logger.ts
|
|
2
|
+
import { createLogger } from "hazo_core";
|
|
3
|
+
var console_logger = {
|
|
4
|
+
info: (m, d) => d ? console.log(`[hazo_secure] ${m}`, d) : console.log(`[hazo_secure] ${m}`),
|
|
5
|
+
debug: (m, d) => d ? console.debug(`[hazo_secure] ${m}`, d) : console.debug(`[hazo_secure] ${m}`),
|
|
6
|
+
warn: (m, d) => d ? console.warn(`[hazo_secure] ${m}`, d) : console.warn(`[hazo_secure] ${m}`),
|
|
7
|
+
error: (m, d) => d ? console.error(`[hazo_secure] ${m}`, d) : console.error(`[hazo_secure] ${m}`)
|
|
8
|
+
};
|
|
9
|
+
function build_default_logger() {
|
|
10
|
+
try {
|
|
11
|
+
return createLogger("hazo_secure");
|
|
12
|
+
} catch {
|
|
13
|
+
return console_logger;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
var current_logger = null;
|
|
17
|
+
function set_logger(logger) {
|
|
18
|
+
current_logger = logger ?? null;
|
|
19
|
+
}
|
|
20
|
+
function get_logger() {
|
|
21
|
+
if (!current_logger) {
|
|
22
|
+
current_logger = build_default_logger();
|
|
23
|
+
}
|
|
24
|
+
return current_logger;
|
|
25
|
+
}
|
|
26
|
+
var noop_logger = {
|
|
27
|
+
debug: () => {
|
|
4
28
|
},
|
|
5
|
-
info() {
|
|
29
|
+
info: () => {
|
|
6
30
|
},
|
|
7
|
-
warn() {
|
|
31
|
+
warn: () => {
|
|
8
32
|
},
|
|
9
|
-
error() {
|
|
33
|
+
error: () => {
|
|
10
34
|
}
|
|
11
35
|
};
|
|
12
|
-
|
|
36
|
+
|
|
37
|
+
// src/index.ts
|
|
38
|
+
var PACKAGE_VERSION = "1.1.0";
|
|
13
39
|
export {
|
|
14
|
-
NoopLogger,
|
|
15
|
-
PACKAGE_VERSION
|
|
40
|
+
noop_logger as NoopLogger,
|
|
41
|
+
PACKAGE_VERSION,
|
|
42
|
+
get_logger,
|
|
43
|
+
noop_logger,
|
|
44
|
+
set_logger
|
|
16
45
|
};
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated MemoryRateLimitStore has moved to `hazo_api/rate_limit` per D-014.
|
|
3
|
+
* This implementation remains in hazo_secure for one major version for backwards
|
|
4
|
+
* compatibility and will be removed in hazo_secure@2.0.0.
|
|
5
|
+
* Update your imports: `import { MemoryRateLimitStore } from 'hazo_api/rate_limit'`
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
interface MemoryStoreOptions {
|
|
2
9
|
now?: () => number;
|
|
3
10
|
sweepIntervalMs?: number;
|
|
@@ -8,7 +15,7 @@ declare class MemoryRateLimitStore implements RateLimitStore {
|
|
|
8
15
|
private sweepTimer;
|
|
9
16
|
constructor(opts?: MemoryStoreOptions);
|
|
10
17
|
get(key: string): Promise<RateLimitState | null>;
|
|
11
|
-
increment(key: string, windowMs: number): Promise<RateLimitState>;
|
|
18
|
+
increment(key: string, windowMs: number, _max?: number): Promise<RateLimitState>;
|
|
12
19
|
reset(key: string): Promise<void>;
|
|
13
20
|
size(): number;
|
|
14
21
|
sweep(): void;
|
|
@@ -16,6 +23,50 @@ declare class MemoryRateLimitStore implements RateLimitStore {
|
|
|
16
23
|
}
|
|
17
24
|
declare function getDefaultMemoryStore(): MemoryRateLimitStore;
|
|
18
25
|
|
|
26
|
+
type BucketRow = {
|
|
27
|
+
id: string;
|
|
28
|
+
tokens: number;
|
|
29
|
+
last_refill_at: string;
|
|
30
|
+
max_tokens: number;
|
|
31
|
+
refill_rate_per_ms: number;
|
|
32
|
+
};
|
|
33
|
+
type SvcLike = {
|
|
34
|
+
findOneBy(criteria: Record<string, unknown>): Promise<BucketRow | null>;
|
|
35
|
+
insert(data: Partial<BucketRow> | Partial<BucketRow>[]): Promise<BucketRow[]>;
|
|
36
|
+
updateById(id: unknown, patch: Partial<BucketRow>): Promise<BucketRow[]>;
|
|
37
|
+
deleteById(id: unknown): Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
interface ConnectRateLimitStoreOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Factory that resolves to a hazo_connect HazoConnectAdapter. Used in production.
|
|
42
|
+
* Call createCrudService from hazo_connect/server before passing.
|
|
43
|
+
*/
|
|
44
|
+
getAdapter?: () => unknown | Promise<unknown>;
|
|
45
|
+
/** For testing: inject a CrudService stub directly. */
|
|
46
|
+
_svc?: () => SvcLike | Promise<SvcLike>;
|
|
47
|
+
/** Injectable clock. Defaults to Date.now. */
|
|
48
|
+
now?: () => number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Rate-limit store backed by hazo_connect (PostgreSQL or SQLite).
|
|
52
|
+
* Uses a token bucket algorithm — no boundary burst, continuous token refill.
|
|
53
|
+
*
|
|
54
|
+
* Requires the hazo_rl_buckets table from migrations/001_hazo_rl_buckets.sql.
|
|
55
|
+
*
|
|
56
|
+
* Token bucket → RateLimitState mapping:
|
|
57
|
+
* count = max - tokensAfterConsumption
|
|
58
|
+
* count <= max → allowed (tokensAfter >= 0)
|
|
59
|
+
* count > max → denied (tokensAfter < 0)
|
|
60
|
+
*/
|
|
61
|
+
declare class ConnectRateLimitStore implements RateLimitStore {
|
|
62
|
+
#private;
|
|
63
|
+
constructor(opts: ConnectRateLimitStoreOptions);
|
|
64
|
+
private svc;
|
|
65
|
+
get(key: string): Promise<RateLimitState | null>;
|
|
66
|
+
increment(key: string, windowMs: number, max?: number): Promise<RateLimitState>;
|
|
67
|
+
reset(key: string): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
19
70
|
interface RateLimitState {
|
|
20
71
|
count: number;
|
|
21
72
|
resetAt: number;
|
|
@@ -28,7 +79,8 @@ interface RateLimitDecision {
|
|
|
28
79
|
}
|
|
29
80
|
interface RateLimitStore {
|
|
30
81
|
get(key: string): Promise<RateLimitState | null>;
|
|
31
|
-
|
|
82
|
+
/** max is optional for backward compat; ConnectRateLimitStore requires it. */
|
|
83
|
+
increment(key: string, windowMs: number, max?: number): Promise<RateLimitState>;
|
|
32
84
|
reset(key: string): Promise<void>;
|
|
33
85
|
}
|
|
34
86
|
interface RateLimiterOptions {
|
|
@@ -51,4 +103,4 @@ interface WithRateLimitOptions extends RateLimiterOptions {
|
|
|
51
103
|
type AnyRouteHandler = (...args: any[]) => Promise<Response>;
|
|
52
104
|
declare function withRateLimit<H extends AnyRouteHandler>(handler: H, opts: WithRateLimitOptions): H;
|
|
53
105
|
|
|
54
|
-
export { MemoryRateLimitStore, type RateLimitDecision, type RateLimitState, type RateLimitStore, type RateLimiter, type RateLimiterOptions, type WithRateLimitOptions, checkRateLimit, createRateLimiter, defaultKeyResolver, getDefaultMemoryStore, withRateLimit };
|
|
106
|
+
export { ConnectRateLimitStore, type ConnectRateLimitStoreOptions, MemoryRateLimitStore, type RateLimitDecision, type RateLimitState, type RateLimitStore, type RateLimiter, type RateLimiterOptions, type WithRateLimitOptions, checkRateLimit, createRateLimiter, defaultKeyResolver, getDefaultMemoryStore, withRateLimit };
|