@glidevvr/storage-payload-error-logger-pkg 0.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/README.md +229 -0
- package/dist/chunk-EKIOMBUO.mjs +282 -0
- package/dist/chunk-EKIOMBUO.mjs.map +1 -0
- package/dist/index.d.cts +99 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.js +332 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +37 -0
- package/dist/index.mjs.map +1 -0
- package/dist/payload-endpoint/index.d.cts +96 -0
- package/dist/payload-endpoint/index.d.ts +96 -0
- package/dist/payload-endpoint/index.js +206 -0
- package/dist/payload-endpoint/index.js.map +1 -0
- package/dist/payload-endpoint/index.mjs +179 -0
- package/dist/payload-endpoint/index.mjs.map +1 -0
- package/dist/react/index.d.cts +26 -0
- package/dist/react/index.d.ts +26 -0
- package/dist/react/index.js +302 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/index.mjs +28 -0
- package/dist/react/index.mjs.map +1 -0
- package/dist/types-BLz-TUBl.d.cts +198 -0
- package/dist/types-BLz-TUBl.d.ts +198 -0
- package/package.json +62 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { c as LogEvent } from '../types-BLz-TUBl.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shape of a Payload v3 endpoint. Declared here as a plain interface
|
|
5
|
+
* (rather than imported from `payload`) so this package can be used without
|
|
6
|
+
* `payload` installed — `payload` is an optional peer dependency. Anything
|
|
7
|
+
* matching this shape can be added to the Payload config's `endpoints` array.
|
|
8
|
+
*/
|
|
9
|
+
interface PayloadEndpoint {
|
|
10
|
+
path: string;
|
|
11
|
+
method: "post" | "get" | "put" | "patch" | "delete";
|
|
12
|
+
handler: (req: PayloadRequest) => Promise<Response>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The shape of the JSON body our endpoint accepts. Each request is a batch of
|
|
16
|
+
* `events` plus the reCAPTCHA token the helper acquired in the browser.
|
|
17
|
+
*/
|
|
18
|
+
interface IncomingBody {
|
|
19
|
+
events: LogEvent[];
|
|
20
|
+
rcToken: string | null;
|
|
21
|
+
}
|
|
22
|
+
interface PayloadRequest {
|
|
23
|
+
headers: {
|
|
24
|
+
get(name: string): string | null;
|
|
25
|
+
};
|
|
26
|
+
json: () => Promise<IncomingBody>;
|
|
27
|
+
payload: PayloadInstance;
|
|
28
|
+
}
|
|
29
|
+
interface PayloadInstance {
|
|
30
|
+
find: (args: PayloadFindArgs) => Promise<{
|
|
31
|
+
docs: PayloadDoc[];
|
|
32
|
+
}>;
|
|
33
|
+
create: (args: PayloadCreateArgs) => Promise<{
|
|
34
|
+
id: string | number;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
interface PayloadFindArgs {
|
|
38
|
+
collection: string;
|
|
39
|
+
limit?: number;
|
|
40
|
+
pagination?: boolean;
|
|
41
|
+
where?: TenantDomainWhere;
|
|
42
|
+
}
|
|
43
|
+
interface TenantDomainWhere {
|
|
44
|
+
[field: string]: {
|
|
45
|
+
contains?: string;
|
|
46
|
+
equals?: string | number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
interface PayloadCreateArgs {
|
|
50
|
+
collection: string;
|
|
51
|
+
data: IssueEventCreateData;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The shape of data we pass to `payload.create('issue-events', { data })`.
|
|
55
|
+
* It's a `LogEvent` plus the `tenant` we resolved from the request's `Origin`.
|
|
56
|
+
*/
|
|
57
|
+
type IssueEventCreateData = LogEvent & {
|
|
58
|
+
tenant: string | number;
|
|
59
|
+
};
|
|
60
|
+
interface PayloadDoc {
|
|
61
|
+
id: string | number;
|
|
62
|
+
domain?: string;
|
|
63
|
+
}
|
|
64
|
+
interface CreateErrorLoggerEndpointOptions {
|
|
65
|
+
/** Path on the Payload server, relative to the API root. Defaults to `/log-error`. */
|
|
66
|
+
path?: string;
|
|
67
|
+
/** Google Cloud project ID hosting the reCAPTCHA Enterprise key. */
|
|
68
|
+
recaptchaProjectId: string;
|
|
69
|
+
/** Google Cloud API key with reCAPTCHA Enterprise enabled. */
|
|
70
|
+
recaptchaApiKey: string;
|
|
71
|
+
/** The reCAPTCHA Enterprise site key (also used by the browser to acquire tokens). */
|
|
72
|
+
recaptchaSiteKey: string;
|
|
73
|
+
/** Action name we expect on incoming tokens. Must match the helper's `recaptchaAction`. Defaults to "log_error". */
|
|
74
|
+
recaptchaAction?: string;
|
|
75
|
+
/** Minimum score (0.0–1.0) to accept. Defaults to 0.5. */
|
|
76
|
+
recaptchaThreshold?: number;
|
|
77
|
+
/** Most events one IP can send per minute. Defaults to 60. */
|
|
78
|
+
perIpMaxPerMinute?: number;
|
|
79
|
+
/** Most events one tenant can send per day. Defaults to 10_000. */
|
|
80
|
+
perTenantMaxPerDay?: number;
|
|
81
|
+
/** Optional hook fired when a tenant trips the daily cap, e.g. for alerting. */
|
|
82
|
+
onTenantCapExceeded?: (tenantId: string | number) => void;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Build a Payload endpoint that accepts cross-origin POSTs of error events
|
|
86
|
+
* from the browser.
|
|
87
|
+
*
|
|
88
|
+
* Each request goes through these checks in order: verify the reCAPTCHA
|
|
89
|
+
* token, look up the tenant by the request's `Origin` header, apply the
|
|
90
|
+
* per-IP burst limit, apply the per-tenant daily limit, then create one
|
|
91
|
+
* `issue-events` doc per event. The `IssueEvents.beforeChange` hook handles
|
|
92
|
+
* grouping events into the parent `Issues` row.
|
|
93
|
+
*/
|
|
94
|
+
declare function createErrorLoggerEndpoint(options: CreateErrorLoggerEndpointOptions): PayloadEndpoint;
|
|
95
|
+
|
|
96
|
+
export { type CreateErrorLoggerEndpointOptions, type IncomingBody, type PayloadEndpoint, createErrorLoggerEndpoint };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/payload-endpoint/index.ts
|
|
21
|
+
var payload_endpoint_exports = {};
|
|
22
|
+
__export(payload_endpoint_exports, {
|
|
23
|
+
createErrorLoggerEndpoint: () => createErrorLoggerEndpoint
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(payload_endpoint_exports);
|
|
26
|
+
|
|
27
|
+
// src/payload-endpoint/normalizeOrigin.ts
|
|
28
|
+
function normalizeOrigin(input) {
|
|
29
|
+
if (!input) return null;
|
|
30
|
+
const candidate = input.includes("://") ? input : `https://${input}`;
|
|
31
|
+
let host;
|
|
32
|
+
try {
|
|
33
|
+
host = new URL(candidate).hostname;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
host = host.toLowerCase();
|
|
38
|
+
if (host.endsWith(".")) host = host.slice(0, -1);
|
|
39
|
+
return host || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/payload-endpoint/verifyRecaptcha.ts
|
|
43
|
+
async function verifyRecaptcha(opts) {
|
|
44
|
+
const url = `https://recaptchaenterprise.googleapis.com/v1/projects/${opts.projectId}/assessments?key=${encodeURIComponent(opts.apiKey)}`;
|
|
45
|
+
const body = {
|
|
46
|
+
event: {
|
|
47
|
+
token: opts.token,
|
|
48
|
+
siteKey: opts.siteKey,
|
|
49
|
+
expectedAction: opts.expectedAction,
|
|
50
|
+
...opts.userAgent ? { userAgent: opts.userAgent } : {},
|
|
51
|
+
...opts.userIpAddress ? { userIpAddress: opts.userIpAddress } : {}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(url, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
body: JSON.stringify(body)
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) return false;
|
|
61
|
+
const json = await res.json();
|
|
62
|
+
if (!json.tokenProperties?.valid) return false;
|
|
63
|
+
if (json.tokenProperties.action !== opts.expectedAction) return false;
|
|
64
|
+
const score = json.riskAnalysis?.score ?? 0;
|
|
65
|
+
if (score < opts.threshold) return false;
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/payload-endpoint/rateLimit.ts
|
|
73
|
+
var SWEEP_EVERY = 1e3;
|
|
74
|
+
function createRateLimiter(opts) {
|
|
75
|
+
const hits = /* @__PURE__ */ new Map();
|
|
76
|
+
let callsSinceSweep = 0;
|
|
77
|
+
function sweepEmpty(now) {
|
|
78
|
+
for (const [key, arr] of hits) {
|
|
79
|
+
const filtered = arr.filter((t) => now - t < opts.windowMs);
|
|
80
|
+
if (filtered.length === 0) hits.delete(key);
|
|
81
|
+
else if (filtered.length !== arr.length) hits.set(key, filtered);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
allow(key) {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
callsSinceSweep++;
|
|
88
|
+
if (callsSinceSweep >= SWEEP_EVERY) {
|
|
89
|
+
sweepEmpty(now);
|
|
90
|
+
callsSinceSweep = 0;
|
|
91
|
+
}
|
|
92
|
+
const arr = (hits.get(key) ?? []).filter((t) => now - t < opts.windowMs);
|
|
93
|
+
if (arr.length >= opts.max) {
|
|
94
|
+
hits.set(key, arr);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
arr.push(now);
|
|
98
|
+
hits.set(key, arr);
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
_sizeForTests() {
|
|
102
|
+
return hits.size;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/payload-endpoint/index.ts
|
|
108
|
+
function readClientIp(req) {
|
|
109
|
+
return req.headers.get("cf-connecting-ip") ?? req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
|
|
110
|
+
}
|
|
111
|
+
var MAX_MESSAGE_CHARS = 500;
|
|
112
|
+
var MAX_STACK_CHARS = 1e4;
|
|
113
|
+
var MAX_BODY_BYTES = 256 * 1024;
|
|
114
|
+
function clampEventForStorage(event) {
|
|
115
|
+
return {
|
|
116
|
+
...event,
|
|
117
|
+
message: event.message?.slice(0, MAX_MESSAGE_CHARS) ?? event.message,
|
|
118
|
+
stack: event.stack?.slice(0, MAX_STACK_CHARS) ?? event.stack
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function createErrorLoggerEndpoint(options) {
|
|
122
|
+
const ipLimiter = createRateLimiter({
|
|
123
|
+
windowMs: 6e4,
|
|
124
|
+
max: options.perIpMaxPerMinute ?? 60
|
|
125
|
+
});
|
|
126
|
+
const tenantLimiter = createRateLimiter({
|
|
127
|
+
windowMs: 24 * 60 * 6e4,
|
|
128
|
+
max: options.perTenantMaxPerDay ?? 1e4
|
|
129
|
+
});
|
|
130
|
+
return {
|
|
131
|
+
path: options.path ?? "/log-error",
|
|
132
|
+
method: "post",
|
|
133
|
+
handler: async (req) => {
|
|
134
|
+
const contentLength = Number(req.headers.get("content-length") ?? "0");
|
|
135
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
|
|
136
|
+
return new Response(JSON.stringify({ error: "body too large" }), { status: 413 });
|
|
137
|
+
}
|
|
138
|
+
let body;
|
|
139
|
+
try {
|
|
140
|
+
body = await req.json();
|
|
141
|
+
} catch {
|
|
142
|
+
return new Response(JSON.stringify({ error: "invalid body" }), { status: 400 });
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(body?.events) || body.events.length === 0) {
|
|
145
|
+
return new Response(JSON.stringify({ error: "no events" }), { status: 400 });
|
|
146
|
+
}
|
|
147
|
+
if (!body.rcToken) {
|
|
148
|
+
return new Response(JSON.stringify({ error: "no recaptcha token" }), { status: 403 });
|
|
149
|
+
}
|
|
150
|
+
const userAgent = req.headers.get("user-agent") ?? void 0;
|
|
151
|
+
const clientIp = readClientIp(req);
|
|
152
|
+
const recaptchaOk = await verifyRecaptcha({
|
|
153
|
+
token: body.rcToken,
|
|
154
|
+
siteKey: options.recaptchaSiteKey,
|
|
155
|
+
projectId: options.recaptchaProjectId,
|
|
156
|
+
apiKey: options.recaptchaApiKey,
|
|
157
|
+
expectedAction: options.recaptchaAction ?? "log_error",
|
|
158
|
+
threshold: options.recaptchaThreshold ?? 0.5,
|
|
159
|
+
userAgent,
|
|
160
|
+
userIpAddress: clientIp ?? void 0
|
|
161
|
+
});
|
|
162
|
+
if (!recaptchaOk) {
|
|
163
|
+
return new Response(JSON.stringify({ error: "recaptcha rejected" }), { status: 403 });
|
|
164
|
+
}
|
|
165
|
+
const originHeader = req.headers.get("origin");
|
|
166
|
+
const originHost = normalizeOrigin(originHeader);
|
|
167
|
+
if (!originHost) {
|
|
168
|
+
return new Response(JSON.stringify({ error: "invalid origin" }), { status: 403 });
|
|
169
|
+
}
|
|
170
|
+
const tenantsResult = await req.payload.find({
|
|
171
|
+
collection: "tenants",
|
|
172
|
+
where: { domain: { contains: originHost } },
|
|
173
|
+
pagination: false
|
|
174
|
+
});
|
|
175
|
+
const tenant = tenantsResult.docs.find(
|
|
176
|
+
(t) => normalizeOrigin(t.domain ?? null) === originHost
|
|
177
|
+
);
|
|
178
|
+
if (!tenant) {
|
|
179
|
+
return new Response(JSON.stringify({ error: "unknown origin" }), { status: 403 });
|
|
180
|
+
}
|
|
181
|
+
if (clientIp !== null && !ipLimiter.allow(clientIp)) {
|
|
182
|
+
return new Response(JSON.stringify({ error: "rate limited" }), { status: 429 });
|
|
183
|
+
}
|
|
184
|
+
if (!tenantLimiter.allow(String(tenant.id))) {
|
|
185
|
+
options.onTenantCapExceeded?.(tenant.id);
|
|
186
|
+
return new Response(JSON.stringify({ error: "tenant cap exceeded" }), { status: 429 });
|
|
187
|
+
}
|
|
188
|
+
void Promise.all(
|
|
189
|
+
body.events.map((event) => {
|
|
190
|
+
const clamped = clampEventForStorage(event);
|
|
191
|
+
return req.payload.create({
|
|
192
|
+
collection: "issue-events",
|
|
193
|
+
data: { ...clamped, tenant: tenant.id }
|
|
194
|
+
}).catch(() => {
|
|
195
|
+
});
|
|
196
|
+
})
|
|
197
|
+
);
|
|
198
|
+
return new Response(JSON.stringify({ accepted: body.events.length }), { status: 202 });
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
203
|
+
0 && (module.exports = {
|
|
204
|
+
createErrorLoggerEndpoint
|
|
205
|
+
});
|
|
206
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/payload-endpoint/index.ts","../../src/payload-endpoint/normalizeOrigin.ts","../../src/payload-endpoint/verifyRecaptcha.ts","../../src/payload-endpoint/rateLimit.ts"],"sourcesContent":["import { normalizeOrigin } from \"./normalizeOrigin\";\nimport { verifyRecaptcha } from \"./verifyRecaptcha\";\nimport { createRateLimiter, type RateLimiter } from \"./rateLimit\";\nimport type { LogEvent } from \"../types\";\n\n/**\n * The shape of a Payload v3 endpoint. Declared here as a plain interface\n * (rather than imported from `payload`) so this package can be used without\n * `payload` installed — `payload` is an optional peer dependency. Anything\n * matching this shape can be added to the Payload config's `endpoints` array.\n */\nexport interface PayloadEndpoint {\n path: string;\n method: \"post\" | \"get\" | \"put\" | \"patch\" | \"delete\";\n handler: (req: PayloadRequest) => Promise<Response>;\n}\n\n/**\n * The shape of the JSON body our endpoint accepts. Each request is a batch of\n * `events` plus the reCAPTCHA token the helper acquired in the browser.\n */\nexport interface IncomingBody {\n events: LogEvent[];\n rcToken: string | null;\n}\n\ninterface PayloadRequest {\n headers: { get(name: string): string | null };\n json: () => Promise<IncomingBody>;\n payload: PayloadInstance;\n}\n\ninterface PayloadInstance {\n find: (args: PayloadFindArgs) => Promise<{ docs: PayloadDoc[] }>;\n create: (args: PayloadCreateArgs) => Promise<{ id: string | number }>;\n}\n\ninterface PayloadFindArgs {\n collection: string;\n limit?: number;\n pagination?: boolean;\n where?: TenantDomainWhere;\n}\n\n// Scoped narrowly: this file only ever queries `domain.contains` on tenants.\n// Rename signals scope — widening for new query shapes should be deliberate, not accidental.\ninterface TenantDomainWhere {\n [field: string]: { contains?: string; equals?: string | number };\n}\n\ninterface PayloadCreateArgs {\n collection: string;\n data: IssueEventCreateData;\n}\n\n/**\n * The shape of data we pass to `payload.create('issue-events', { data })`.\n * It's a `LogEvent` plus the `tenant` we resolved from the request's `Origin`.\n */\ntype IssueEventCreateData = LogEvent & { tenant: string | number };\n\ninterface PayloadDoc {\n id: string | number;\n domain?: string;\n}\n\nexport interface CreateErrorLoggerEndpointOptions {\n /** Path on the Payload server, relative to the API root. Defaults to `/log-error`. */\n path?: string;\n /** Google Cloud project ID hosting the reCAPTCHA Enterprise key. */\n recaptchaProjectId: string;\n /** Google Cloud API key with reCAPTCHA Enterprise enabled. */\n recaptchaApiKey: string;\n /** The reCAPTCHA Enterprise site key (also used by the browser to acquire tokens). */\n recaptchaSiteKey: string;\n /** Action name we expect on incoming tokens. Must match the helper's `recaptchaAction`. Defaults to \"log_error\". */\n recaptchaAction?: string;\n /** Minimum score (0.0–1.0) to accept. Defaults to 0.5. */\n recaptchaThreshold?: number;\n /** Most events one IP can send per minute. Defaults to 60. */\n perIpMaxPerMinute?: number;\n /** Most events one tenant can send per day. Defaults to 10_000. */\n perTenantMaxPerDay?: number;\n /** Optional hook fired when a tenant trips the daily cap, e.g. for alerting. */\n onTenantCapExceeded?: (tenantId: string | number) => void;\n}\n\n// Returns null when no upstream header carries the client IP. Callers must\n// bypass per-IP rate limiting in that case so local dev / misconfigured\n// upstreams don't collapse every IP-less request into one shared bucket.\nfunction readClientIp(req: PayloadRequest): string | null {\n return (\n req.headers.get(\"cf-connecting-ip\") ??\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ??\n null\n );\n}\n\n// IssueEvents storage caps: message text and stack trace each have hard upper\n// bounds on the collection. Trim defensively here so a direct REST POST with\n// oversized strings becomes a successful best-effort log instead of a 400.\n// Values must match the maxLength settings in collections/IssueEvents/index.ts.\nconst MAX_MESSAGE_CHARS = 500;\nconst MAX_STACK_CHARS = 10_000;\n\n// Upper bound on request body size. Generous for a normal batch (~10 events\n// after per-field clamping) but small enough that a malicious POST can't burn\n// CPU on parsing + reCAPTCHA verify before any rate limit runs.\nconst MAX_BODY_BYTES = 256 * 1024;\n\nfunction clampEventForStorage(event: LogEvent): LogEvent {\n return {\n ...event,\n message: event.message?.slice(0, MAX_MESSAGE_CHARS) ?? event.message,\n stack: event.stack?.slice(0, MAX_STACK_CHARS) ?? event.stack,\n };\n}\n\n/**\n * Build a Payload endpoint that accepts cross-origin POSTs of error events\n * from the browser.\n *\n * Each request goes through these checks in order: verify the reCAPTCHA\n * token, look up the tenant by the request's `Origin` header, apply the\n * per-IP burst limit, apply the per-tenant daily limit, then create one\n * `issue-events` doc per event. The `IssueEvents.beforeChange` hook handles\n * grouping events into the parent `Issues` row.\n */\nexport function createErrorLoggerEndpoint(\n options: CreateErrorLoggerEndpointOptions,\n): PayloadEndpoint {\n const ipLimiter: RateLimiter = createRateLimiter({\n windowMs: 60_000,\n max: options.perIpMaxPerMinute ?? 60,\n });\n const tenantLimiter: RateLimiter = createRateLimiter({\n windowMs: 24 * 60 * 60_000,\n max: options.perTenantMaxPerDay ?? 10_000,\n });\n\n return {\n path: options.path ?? \"/log-error\",\n method: \"post\",\n handler: async (req) => {\n // Reject oversized bodies before parsing or reCAPTCHA verify.\n const contentLength = Number(req.headers.get(\"content-length\") ?? \"0\");\n if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {\n return new Response(JSON.stringify({ error: \"body too large\" }), { status: 413 });\n }\n\n let body: IncomingBody;\n try {\n body = await req.json();\n } catch {\n return new Response(JSON.stringify({ error: \"invalid body\" }), { status: 400 });\n }\n\n if (!Array.isArray(body?.events) || body.events.length === 0) {\n return new Response(JSON.stringify({ error: \"no events\" }), { status: 400 });\n }\n\n // 1. reCAPTCHA\n if (!body.rcToken) {\n return new Response(JSON.stringify({ error: \"no recaptcha token\" }), { status: 403 });\n }\n const userAgent = req.headers.get(\"user-agent\") ?? undefined;\n const clientIp = readClientIp(req);\n const recaptchaOk = await verifyRecaptcha({\n token: body.rcToken,\n siteKey: options.recaptchaSiteKey,\n projectId: options.recaptchaProjectId,\n apiKey: options.recaptchaApiKey,\n expectedAction: options.recaptchaAction ?? \"log_error\",\n threshold: options.recaptchaThreshold ?? 0.5,\n userAgent,\n userIpAddress: clientIp ?? undefined,\n });\n if (!recaptchaOk) {\n return new Response(JSON.stringify({ error: \"recaptcha rejected\" }), { status: 403 });\n }\n\n // 2. Origin → tenant lookup\n const originHeader = req.headers.get(\"origin\");\n const originHost = normalizeOrigin(originHeader);\n if (!originHost) {\n return new Response(JSON.stringify({ error: \"invalid origin\" }), { status: 403 });\n }\n // Narrow by substring so we don't load every tenant; normalize-and-compare below rejects false positives.\n const tenantsResult = await req.payload.find({\n collection: \"tenants\",\n where: { domain: { contains: originHost } },\n pagination: false,\n });\n const tenant = tenantsResult.docs.find(\n (t) => normalizeOrigin(t.domain ?? null) === originHost,\n );\n if (!tenant) {\n return new Response(JSON.stringify({ error: \"unknown origin\" }), { status: 403 });\n }\n\n // 3. Per-IP rate limit. Skip when no real IP header was present —\n // the per-tenant daily cap below is still in force as a safety net.\n if (clientIp !== null && !ipLimiter.allow(clientIp)) {\n return new Response(JSON.stringify({ error: \"rate limited\" }), { status: 429 });\n }\n\n // 4. Per-tenant volume cap\n if (!tenantLimiter.allow(String(tenant.id))) {\n options.onTenantCapExceeded?.(tenant.id);\n return new Response(JSON.stringify({ error: \"tenant cap exceeded\" }), { status: 429 });\n }\n\n // 5. Create events (dedup hook on IssueEvents handles parent Issue find-or-create)\n // Fire-and-forget — return 202 without awaiting all creates.\n void Promise.all(\n body.events.map((event) => {\n const clamped = clampEventForStorage(event);\n return req.payload\n .create({\n collection: \"issue-events\",\n data: { ...clamped, tenant: tenant.id },\n })\n .catch(() => {\n /* swallow per-event failures; cascade guard lives in the dedup hook */\n });\n }),\n );\n\n return new Response(JSON.stringify({ accepted: body.events.length }), { status: 202 });\n },\n };\n}\n","/**\n * Reduce a hostname or full URL to a plain lowercase hostname so two values\n * can be compared during tenant lookup. For example,\n * `https://AcmeStorage.com:443/foo` and `acmestorage.com.` both become\n * `acmestorage.com`.\n *\n * Strips scheme, port, path, query, fragment, trailing slash, and a trailing\n * dot (like the one in `acmestorage.com.`). The incoming `Origin` header and\n * the stored `tenant.domain` are both run through this so cosmetic\n * differences don't cause a miss. Returns `null` if the input is empty or\n * can't be parsed.\n *\n * Note: this does NOT strip `www` (that's a different host) or unify schemes\n * (`http` vs `https` are different origins for browser security purposes).\n */\nexport function normalizeOrigin(input: string | null | undefined): string | null {\n if (!input) return null;\n const candidate = input.includes(\"://\") ? input : `https://${input}`;\n let host: string;\n try {\n host = new URL(candidate).hostname;\n } catch {\n return null;\n }\n host = host.toLowerCase();\n if (host.endsWith(\".\")) host = host.slice(0, -1);\n return host || null;\n}\n","export interface VerifyRecaptchaOptions {\n /** The Enterprise token from the browser. */\n token: string;\n /** The reCAPTCHA Enterprise site key. */\n siteKey: string;\n /** Google Cloud project ID hosting the reCAPTCHA Enterprise key. */\n projectId: string;\n /** Google Cloud API key with reCAPTCHA Enterprise enabled. */\n apiKey: string;\n /** The action we expect on the token (e.g. \"log_error\"). */\n expectedAction: string;\n /** Minimum score (0.0–1.0) to accept. */\n threshold: number;\n /** Optional user agent + client IP forwarded to Enterprise for stronger risk signals. */\n userAgent?: string;\n userIpAddress?: string;\n}\n\n/**\n * Verify a reCAPTCHA Enterprise token by creating an assessment.\n *\n * Returns true if the token is valid AND the action matches AND the score\n * meets the threshold. Returns false on any failure (network, invalid token,\n * action mismatch, low score, malformed response). Never throws — that lets\n * the endpoint cleanly drop the request without trying to parse exception\n * structures.\n */\nexport async function verifyRecaptcha(opts: VerifyRecaptchaOptions): Promise<boolean> {\n const url = `https://recaptchaenterprise.googleapis.com/v1/projects/${opts.projectId}/assessments?key=${encodeURIComponent(opts.apiKey)}`;\n const body = {\n event: {\n token: opts.token,\n siteKey: opts.siteKey,\n expectedAction: opts.expectedAction,\n ...(opts.userAgent ? { userAgent: opts.userAgent } : {}),\n ...(opts.userIpAddress ? { userIpAddress: opts.userIpAddress } : {}),\n },\n };\n\n try {\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(body),\n });\n if (!res.ok) return false;\n\n const json = (await res.json()) as {\n tokenProperties?: { valid?: boolean; action?: string };\n riskAnalysis?: { score?: number };\n };\n\n if (!json.tokenProperties?.valid) return false;\n if (json.tokenProperties.action !== opts.expectedAction) return false;\n const score = json.riskAnalysis?.score ?? 0;\n if (score < opts.threshold) return false;\n return true;\n } catch {\n return false;\n }\n}\n","/** A simple \"is this key allowed to do something right now?\" check. */\nexport interface RateLimiter {\n /** True if the key still has room in the window (and uses one slot); false if it's over. */\n allow(key: string): boolean;\n /** @internal — test-only inspection of the internal Map size. */\n _sizeForTests(): number;\n}\n\nexport interface RateLimiterOptions {\n /** How far back to count hits, in ms. */\n windowMs: number;\n /** Most hits allowed in that window before `allow` starts returning false. */\n max: number;\n}\n\n// Periodic global sweep cadence: every Nth `allow()` call, drop any key whose\n// hits have all aged out. Bounded cost — a long-tail IP that hits once and\n// never returns won't sit in the Map forever.\nconst SWEEP_EVERY = 1000;\n\n/**\n * Returns a rate limiter keyed by a string. Each call to `allow(key)` checks\n * how many hits that key has had in the last `windowMs`; if it's under `max`,\n * it records the hit and returns true, otherwise it returns false.\n *\n * In-memory only — across multiple server instances the count is approximate,\n * which is fine for v1. The endpoint uses two of these: one keyed by client\n * IP (short burst cap) and one keyed by tenant (daily volume cap).\n *\n * Memory hygiene: a global sweep runs every {@link SWEEP_EVERY} calls and\n * deletes keys whose hit arrays have fully aged out — keeps the Map bounded\n * over long uptimes when long-tail keys (one-shot IPs) would otherwise sit\n * there forever.\n */\nexport function createRateLimiter(opts: RateLimiterOptions): RateLimiter {\n const hits = new Map<string, number[]>();\n let callsSinceSweep = 0;\n\n function sweepEmpty(now: number): void {\n for (const [key, arr] of hits) {\n const filtered = arr.filter((t) => now - t < opts.windowMs);\n if (filtered.length === 0) hits.delete(key);\n else if (filtered.length !== arr.length) hits.set(key, filtered);\n }\n }\n\n return {\n allow(key: string): boolean {\n const now = Date.now();\n // Count every call (allowed or denied) toward the sweep cadence so that\n // a single hot, capped key still triggers cleanup of unrelated stragglers.\n callsSinceSweep++;\n if (callsSinceSweep >= SWEEP_EVERY) {\n sweepEmpty(now);\n callsSinceSweep = 0;\n }\n\n const arr = (hits.get(key) ?? []).filter((t) => now - t < opts.windowMs);\n if (arr.length >= opts.max) {\n hits.set(key, arr);\n return false;\n }\n arr.push(now);\n hits.set(key, arr);\n return true;\n },\n _sizeForTests(): number {\n return hits.size;\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// src/payload-endpoint/normalizeOrigin.ts
|
|
2
|
+
function normalizeOrigin(input) {
|
|
3
|
+
if (!input) return null;
|
|
4
|
+
const candidate = input.includes("://") ? input : `https://${input}`;
|
|
5
|
+
let host;
|
|
6
|
+
try {
|
|
7
|
+
host = new URL(candidate).hostname;
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
host = host.toLowerCase();
|
|
12
|
+
if (host.endsWith(".")) host = host.slice(0, -1);
|
|
13
|
+
return host || null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/payload-endpoint/verifyRecaptcha.ts
|
|
17
|
+
async function verifyRecaptcha(opts) {
|
|
18
|
+
const url = `https://recaptchaenterprise.googleapis.com/v1/projects/${opts.projectId}/assessments?key=${encodeURIComponent(opts.apiKey)}`;
|
|
19
|
+
const body = {
|
|
20
|
+
event: {
|
|
21
|
+
token: opts.token,
|
|
22
|
+
siteKey: opts.siteKey,
|
|
23
|
+
expectedAction: opts.expectedAction,
|
|
24
|
+
...opts.userAgent ? { userAgent: opts.userAgent } : {},
|
|
25
|
+
...opts.userIpAddress ? { userIpAddress: opts.userIpAddress } : {}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(url, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify(body)
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) return false;
|
|
35
|
+
const json = await res.json();
|
|
36
|
+
if (!json.tokenProperties?.valid) return false;
|
|
37
|
+
if (json.tokenProperties.action !== opts.expectedAction) return false;
|
|
38
|
+
const score = json.riskAnalysis?.score ?? 0;
|
|
39
|
+
if (score < opts.threshold) return false;
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/payload-endpoint/rateLimit.ts
|
|
47
|
+
var SWEEP_EVERY = 1e3;
|
|
48
|
+
function createRateLimiter(opts) {
|
|
49
|
+
const hits = /* @__PURE__ */ new Map();
|
|
50
|
+
let callsSinceSweep = 0;
|
|
51
|
+
function sweepEmpty(now) {
|
|
52
|
+
for (const [key, arr] of hits) {
|
|
53
|
+
const filtered = arr.filter((t) => now - t < opts.windowMs);
|
|
54
|
+
if (filtered.length === 0) hits.delete(key);
|
|
55
|
+
else if (filtered.length !== arr.length) hits.set(key, filtered);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
allow(key) {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
callsSinceSweep++;
|
|
62
|
+
if (callsSinceSweep >= SWEEP_EVERY) {
|
|
63
|
+
sweepEmpty(now);
|
|
64
|
+
callsSinceSweep = 0;
|
|
65
|
+
}
|
|
66
|
+
const arr = (hits.get(key) ?? []).filter((t) => now - t < opts.windowMs);
|
|
67
|
+
if (arr.length >= opts.max) {
|
|
68
|
+
hits.set(key, arr);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
arr.push(now);
|
|
72
|
+
hits.set(key, arr);
|
|
73
|
+
return true;
|
|
74
|
+
},
|
|
75
|
+
_sizeForTests() {
|
|
76
|
+
return hits.size;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/payload-endpoint/index.ts
|
|
82
|
+
function readClientIp(req) {
|
|
83
|
+
return req.headers.get("cf-connecting-ip") ?? req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
|
|
84
|
+
}
|
|
85
|
+
var MAX_MESSAGE_CHARS = 500;
|
|
86
|
+
var MAX_STACK_CHARS = 1e4;
|
|
87
|
+
var MAX_BODY_BYTES = 256 * 1024;
|
|
88
|
+
function clampEventForStorage(event) {
|
|
89
|
+
return {
|
|
90
|
+
...event,
|
|
91
|
+
message: event.message?.slice(0, MAX_MESSAGE_CHARS) ?? event.message,
|
|
92
|
+
stack: event.stack?.slice(0, MAX_STACK_CHARS) ?? event.stack
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function createErrorLoggerEndpoint(options) {
|
|
96
|
+
const ipLimiter = createRateLimiter({
|
|
97
|
+
windowMs: 6e4,
|
|
98
|
+
max: options.perIpMaxPerMinute ?? 60
|
|
99
|
+
});
|
|
100
|
+
const tenantLimiter = createRateLimiter({
|
|
101
|
+
windowMs: 24 * 60 * 6e4,
|
|
102
|
+
max: options.perTenantMaxPerDay ?? 1e4
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
path: options.path ?? "/log-error",
|
|
106
|
+
method: "post",
|
|
107
|
+
handler: async (req) => {
|
|
108
|
+
const contentLength = Number(req.headers.get("content-length") ?? "0");
|
|
109
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
|
|
110
|
+
return new Response(JSON.stringify({ error: "body too large" }), { status: 413 });
|
|
111
|
+
}
|
|
112
|
+
let body;
|
|
113
|
+
try {
|
|
114
|
+
body = await req.json();
|
|
115
|
+
} catch {
|
|
116
|
+
return new Response(JSON.stringify({ error: "invalid body" }), { status: 400 });
|
|
117
|
+
}
|
|
118
|
+
if (!Array.isArray(body?.events) || body.events.length === 0) {
|
|
119
|
+
return new Response(JSON.stringify({ error: "no events" }), { status: 400 });
|
|
120
|
+
}
|
|
121
|
+
if (!body.rcToken) {
|
|
122
|
+
return new Response(JSON.stringify({ error: "no recaptcha token" }), { status: 403 });
|
|
123
|
+
}
|
|
124
|
+
const userAgent = req.headers.get("user-agent") ?? void 0;
|
|
125
|
+
const clientIp = readClientIp(req);
|
|
126
|
+
const recaptchaOk = await verifyRecaptcha({
|
|
127
|
+
token: body.rcToken,
|
|
128
|
+
siteKey: options.recaptchaSiteKey,
|
|
129
|
+
projectId: options.recaptchaProjectId,
|
|
130
|
+
apiKey: options.recaptchaApiKey,
|
|
131
|
+
expectedAction: options.recaptchaAction ?? "log_error",
|
|
132
|
+
threshold: options.recaptchaThreshold ?? 0.5,
|
|
133
|
+
userAgent,
|
|
134
|
+
userIpAddress: clientIp ?? void 0
|
|
135
|
+
});
|
|
136
|
+
if (!recaptchaOk) {
|
|
137
|
+
return new Response(JSON.stringify({ error: "recaptcha rejected" }), { status: 403 });
|
|
138
|
+
}
|
|
139
|
+
const originHeader = req.headers.get("origin");
|
|
140
|
+
const originHost = normalizeOrigin(originHeader);
|
|
141
|
+
if (!originHost) {
|
|
142
|
+
return new Response(JSON.stringify({ error: "invalid origin" }), { status: 403 });
|
|
143
|
+
}
|
|
144
|
+
const tenantsResult = await req.payload.find({
|
|
145
|
+
collection: "tenants",
|
|
146
|
+
where: { domain: { contains: originHost } },
|
|
147
|
+
pagination: false
|
|
148
|
+
});
|
|
149
|
+
const tenant = tenantsResult.docs.find(
|
|
150
|
+
(t) => normalizeOrigin(t.domain ?? null) === originHost
|
|
151
|
+
);
|
|
152
|
+
if (!tenant) {
|
|
153
|
+
return new Response(JSON.stringify({ error: "unknown origin" }), { status: 403 });
|
|
154
|
+
}
|
|
155
|
+
if (clientIp !== null && !ipLimiter.allow(clientIp)) {
|
|
156
|
+
return new Response(JSON.stringify({ error: "rate limited" }), { status: 429 });
|
|
157
|
+
}
|
|
158
|
+
if (!tenantLimiter.allow(String(tenant.id))) {
|
|
159
|
+
options.onTenantCapExceeded?.(tenant.id);
|
|
160
|
+
return new Response(JSON.stringify({ error: "tenant cap exceeded" }), { status: 429 });
|
|
161
|
+
}
|
|
162
|
+
void Promise.all(
|
|
163
|
+
body.events.map((event) => {
|
|
164
|
+
const clamped = clampEventForStorage(event);
|
|
165
|
+
return req.payload.create({
|
|
166
|
+
collection: "issue-events",
|
|
167
|
+
data: { ...clamped, tenant: tenant.id }
|
|
168
|
+
}).catch(() => {
|
|
169
|
+
});
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
return new Response(JSON.stringify({ accepted: body.events.length }), { status: 202 });
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
export {
|
|
177
|
+
createErrorLoggerEndpoint
|
|
178
|
+
};
|
|
179
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/payload-endpoint/normalizeOrigin.ts","../../src/payload-endpoint/verifyRecaptcha.ts","../../src/payload-endpoint/rateLimit.ts","../../src/payload-endpoint/index.ts"],"sourcesContent":["/**\n * Reduce a hostname or full URL to a plain lowercase hostname so two values\n * can be compared during tenant lookup. For example,\n * `https://AcmeStorage.com:443/foo` and `acmestorage.com.` both become\n * `acmestorage.com`.\n *\n * Strips scheme, port, path, query, fragment, trailing slash, and a trailing\n * dot (like the one in `acmestorage.com.`). The incoming `Origin` header and\n * the stored `tenant.domain` are both run through this so cosmetic\n * differences don't cause a miss. Returns `null` if the input is empty or\n * can't be parsed.\n *\n * Note: this does NOT strip `www` (that's a different host) or unify schemes\n * (`http` vs `https` are different origins for browser security purposes).\n */\nexport function normalizeOrigin(input: string | null | undefined): string | null {\n if (!input) return null;\n const candidate = input.includes(\"://\") ? input : `https://${input}`;\n let host: string;\n try {\n host = new URL(candidate).hostname;\n } catch {\n return null;\n }\n host = host.toLowerCase();\n if (host.endsWith(\".\")) host = host.slice(0, -1);\n return host || null;\n}\n","export interface VerifyRecaptchaOptions {\n /** The Enterprise token from the browser. */\n token: string;\n /** The reCAPTCHA Enterprise site key. */\n siteKey: string;\n /** Google Cloud project ID hosting the reCAPTCHA Enterprise key. */\n projectId: string;\n /** Google Cloud API key with reCAPTCHA Enterprise enabled. */\n apiKey: string;\n /** The action we expect on the token (e.g. \"log_error\"). */\n expectedAction: string;\n /** Minimum score (0.0–1.0) to accept. */\n threshold: number;\n /** Optional user agent + client IP forwarded to Enterprise for stronger risk signals. */\n userAgent?: string;\n userIpAddress?: string;\n}\n\n/**\n * Verify a reCAPTCHA Enterprise token by creating an assessment.\n *\n * Returns true if the token is valid AND the action matches AND the score\n * meets the threshold. Returns false on any failure (network, invalid token,\n * action mismatch, low score, malformed response). Never throws — that lets\n * the endpoint cleanly drop the request without trying to parse exception\n * structures.\n */\nexport async function verifyRecaptcha(opts: VerifyRecaptchaOptions): Promise<boolean> {\n const url = `https://recaptchaenterprise.googleapis.com/v1/projects/${opts.projectId}/assessments?key=${encodeURIComponent(opts.apiKey)}`;\n const body = {\n event: {\n token: opts.token,\n siteKey: opts.siteKey,\n expectedAction: opts.expectedAction,\n ...(opts.userAgent ? { userAgent: opts.userAgent } : {}),\n ...(opts.userIpAddress ? { userIpAddress: opts.userIpAddress } : {}),\n },\n };\n\n try {\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(body),\n });\n if (!res.ok) return false;\n\n const json = (await res.json()) as {\n tokenProperties?: { valid?: boolean; action?: string };\n riskAnalysis?: { score?: number };\n };\n\n if (!json.tokenProperties?.valid) return false;\n if (json.tokenProperties.action !== opts.expectedAction) return false;\n const score = json.riskAnalysis?.score ?? 0;\n if (score < opts.threshold) return false;\n return true;\n } catch {\n return false;\n }\n}\n","/** A simple \"is this key allowed to do something right now?\" check. */\nexport interface RateLimiter {\n /** True if the key still has room in the window (and uses one slot); false if it's over. */\n allow(key: string): boolean;\n /** @internal — test-only inspection of the internal Map size. */\n _sizeForTests(): number;\n}\n\nexport interface RateLimiterOptions {\n /** How far back to count hits, in ms. */\n windowMs: number;\n /** Most hits allowed in that window before `allow` starts returning false. */\n max: number;\n}\n\n// Periodic global sweep cadence: every Nth `allow()` call, drop any key whose\n// hits have all aged out. Bounded cost — a long-tail IP that hits once and\n// never returns won't sit in the Map forever.\nconst SWEEP_EVERY = 1000;\n\n/**\n * Returns a rate limiter keyed by a string. Each call to `allow(key)` checks\n * how many hits that key has had in the last `windowMs`; if it's under `max`,\n * it records the hit and returns true, otherwise it returns false.\n *\n * In-memory only — across multiple server instances the count is approximate,\n * which is fine for v1. The endpoint uses two of these: one keyed by client\n * IP (short burst cap) and one keyed by tenant (daily volume cap).\n *\n * Memory hygiene: a global sweep runs every {@link SWEEP_EVERY} calls and\n * deletes keys whose hit arrays have fully aged out — keeps the Map bounded\n * over long uptimes when long-tail keys (one-shot IPs) would otherwise sit\n * there forever.\n */\nexport function createRateLimiter(opts: RateLimiterOptions): RateLimiter {\n const hits = new Map<string, number[]>();\n let callsSinceSweep = 0;\n\n function sweepEmpty(now: number): void {\n for (const [key, arr] of hits) {\n const filtered = arr.filter((t) => now - t < opts.windowMs);\n if (filtered.length === 0) hits.delete(key);\n else if (filtered.length !== arr.length) hits.set(key, filtered);\n }\n }\n\n return {\n allow(key: string): boolean {\n const now = Date.now();\n // Count every call (allowed or denied) toward the sweep cadence so that\n // a single hot, capped key still triggers cleanup of unrelated stragglers.\n callsSinceSweep++;\n if (callsSinceSweep >= SWEEP_EVERY) {\n sweepEmpty(now);\n callsSinceSweep = 0;\n }\n\n const arr = (hits.get(key) ?? []).filter((t) => now - t < opts.windowMs);\n if (arr.length >= opts.max) {\n hits.set(key, arr);\n return false;\n }\n arr.push(now);\n hits.set(key, arr);\n return true;\n },\n _sizeForTests(): number {\n return hits.size;\n },\n };\n}\n","import { normalizeOrigin } from \"./normalizeOrigin\";\nimport { verifyRecaptcha } from \"./verifyRecaptcha\";\nimport { createRateLimiter, type RateLimiter } from \"./rateLimit\";\nimport type { LogEvent } from \"../types\";\n\n/**\n * The shape of a Payload v3 endpoint. Declared here as a plain interface\n * (rather than imported from `payload`) so this package can be used without\n * `payload` installed — `payload` is an optional peer dependency. Anything\n * matching this shape can be added to the Payload config's `endpoints` array.\n */\nexport interface PayloadEndpoint {\n path: string;\n method: \"post\" | \"get\" | \"put\" | \"patch\" | \"delete\";\n handler: (req: PayloadRequest) => Promise<Response>;\n}\n\n/**\n * The shape of the JSON body our endpoint accepts. Each request is a batch of\n * `events` plus the reCAPTCHA token the helper acquired in the browser.\n */\nexport interface IncomingBody {\n events: LogEvent[];\n rcToken: string | null;\n}\n\ninterface PayloadRequest {\n headers: { get(name: string): string | null };\n json: () => Promise<IncomingBody>;\n payload: PayloadInstance;\n}\n\ninterface PayloadInstance {\n find: (args: PayloadFindArgs) => Promise<{ docs: PayloadDoc[] }>;\n create: (args: PayloadCreateArgs) => Promise<{ id: string | number }>;\n}\n\ninterface PayloadFindArgs {\n collection: string;\n limit?: number;\n pagination?: boolean;\n where?: TenantDomainWhere;\n}\n\n// Scoped narrowly: this file only ever queries `domain.contains` on tenants.\n// Rename signals scope — widening for new query shapes should be deliberate, not accidental.\ninterface TenantDomainWhere {\n [field: string]: { contains?: string; equals?: string | number };\n}\n\ninterface PayloadCreateArgs {\n collection: string;\n data: IssueEventCreateData;\n}\n\n/**\n * The shape of data we pass to `payload.create('issue-events', { data })`.\n * It's a `LogEvent` plus the `tenant` we resolved from the request's `Origin`.\n */\ntype IssueEventCreateData = LogEvent & { tenant: string | number };\n\ninterface PayloadDoc {\n id: string | number;\n domain?: string;\n}\n\nexport interface CreateErrorLoggerEndpointOptions {\n /** Path on the Payload server, relative to the API root. Defaults to `/log-error`. */\n path?: string;\n /** Google Cloud project ID hosting the reCAPTCHA Enterprise key. */\n recaptchaProjectId: string;\n /** Google Cloud API key with reCAPTCHA Enterprise enabled. */\n recaptchaApiKey: string;\n /** The reCAPTCHA Enterprise site key (also used by the browser to acquire tokens). */\n recaptchaSiteKey: string;\n /** Action name we expect on incoming tokens. Must match the helper's `recaptchaAction`. Defaults to \"log_error\". */\n recaptchaAction?: string;\n /** Minimum score (0.0–1.0) to accept. Defaults to 0.5. */\n recaptchaThreshold?: number;\n /** Most events one IP can send per minute. Defaults to 60. */\n perIpMaxPerMinute?: number;\n /** Most events one tenant can send per day. Defaults to 10_000. */\n perTenantMaxPerDay?: number;\n /** Optional hook fired when a tenant trips the daily cap, e.g. for alerting. */\n onTenantCapExceeded?: (tenantId: string | number) => void;\n}\n\n// Returns null when no upstream header carries the client IP. Callers must\n// bypass per-IP rate limiting in that case so local dev / misconfigured\n// upstreams don't collapse every IP-less request into one shared bucket.\nfunction readClientIp(req: PayloadRequest): string | null {\n return (\n req.headers.get(\"cf-connecting-ip\") ??\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ??\n null\n );\n}\n\n// IssueEvents storage caps: message text and stack trace each have hard upper\n// bounds on the collection. Trim defensively here so a direct REST POST with\n// oversized strings becomes a successful best-effort log instead of a 400.\n// Values must match the maxLength settings in collections/IssueEvents/index.ts.\nconst MAX_MESSAGE_CHARS = 500;\nconst MAX_STACK_CHARS = 10_000;\n\n// Upper bound on request body size. Generous for a normal batch (~10 events\n// after per-field clamping) but small enough that a malicious POST can't burn\n// CPU on parsing + reCAPTCHA verify before any rate limit runs.\nconst MAX_BODY_BYTES = 256 * 1024;\n\nfunction clampEventForStorage(event: LogEvent): LogEvent {\n return {\n ...event,\n message: event.message?.slice(0, MAX_MESSAGE_CHARS) ?? event.message,\n stack: event.stack?.slice(0, MAX_STACK_CHARS) ?? event.stack,\n };\n}\n\n/**\n * Build a Payload endpoint that accepts cross-origin POSTs of error events\n * from the browser.\n *\n * Each request goes through these checks in order: verify the reCAPTCHA\n * token, look up the tenant by the request's `Origin` header, apply the\n * per-IP burst limit, apply the per-tenant daily limit, then create one\n * `issue-events` doc per event. The `IssueEvents.beforeChange` hook handles\n * grouping events into the parent `Issues` row.\n */\nexport function createErrorLoggerEndpoint(\n options: CreateErrorLoggerEndpointOptions,\n): PayloadEndpoint {\n const ipLimiter: RateLimiter = createRateLimiter({\n windowMs: 60_000,\n max: options.perIpMaxPerMinute ?? 60,\n });\n const tenantLimiter: RateLimiter = createRateLimiter({\n windowMs: 24 * 60 * 60_000,\n max: options.perTenantMaxPerDay ?? 10_000,\n });\n\n return {\n path: options.path ?? \"/log-error\",\n method: \"post\",\n handler: async (req) => {\n // Reject oversized bodies before parsing or reCAPTCHA verify.\n const contentLength = Number(req.headers.get(\"content-length\") ?? \"0\");\n if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {\n return new Response(JSON.stringify({ error: \"body too large\" }), { status: 413 });\n }\n\n let body: IncomingBody;\n try {\n body = await req.json();\n } catch {\n return new Response(JSON.stringify({ error: \"invalid body\" }), { status: 400 });\n }\n\n if (!Array.isArray(body?.events) || body.events.length === 0) {\n return new Response(JSON.stringify({ error: \"no events\" }), { status: 400 });\n }\n\n // 1. reCAPTCHA\n if (!body.rcToken) {\n return new Response(JSON.stringify({ error: \"no recaptcha token\" }), { status: 403 });\n }\n const userAgent = req.headers.get(\"user-agent\") ?? undefined;\n const clientIp = readClientIp(req);\n const recaptchaOk = await verifyRecaptcha({\n token: body.rcToken,\n siteKey: options.recaptchaSiteKey,\n projectId: options.recaptchaProjectId,\n apiKey: options.recaptchaApiKey,\n expectedAction: options.recaptchaAction ?? \"log_error\",\n threshold: options.recaptchaThreshold ?? 0.5,\n userAgent,\n userIpAddress: clientIp ?? undefined,\n });\n if (!recaptchaOk) {\n return new Response(JSON.stringify({ error: \"recaptcha rejected\" }), { status: 403 });\n }\n\n // 2. Origin → tenant lookup\n const originHeader = req.headers.get(\"origin\");\n const originHost = normalizeOrigin(originHeader);\n if (!originHost) {\n return new Response(JSON.stringify({ error: \"invalid origin\" }), { status: 403 });\n }\n // Narrow by substring so we don't load every tenant; normalize-and-compare below rejects false positives.\n const tenantsResult = await req.payload.find({\n collection: \"tenants\",\n where: { domain: { contains: originHost } },\n pagination: false,\n });\n const tenant = tenantsResult.docs.find(\n (t) => normalizeOrigin(t.domain ?? null) === originHost,\n );\n if (!tenant) {\n return new Response(JSON.stringify({ error: \"unknown origin\" }), { status: 403 });\n }\n\n // 3. Per-IP rate limit. Skip when no real IP header was present —\n // the per-tenant daily cap below is still in force as a safety net.\n if (clientIp !== null && !ipLimiter.allow(clientIp)) {\n return new Response(JSON.stringify({ error: \"rate limited\" }), { status: 429 });\n }\n\n // 4. Per-tenant volume cap\n if (!tenantLimiter.allow(String(tenant.id))) {\n options.onTenantCapExceeded?.(tenant.id);\n return new Response(JSON.stringify({ error: \"tenant cap exceeded\" }), { status: 429 });\n }\n\n // 5. Create events (dedup hook on IssueEvents handles parent Issue find-or-create)\n // Fire-and-forget — return 202 without awaiting all creates.\n void Promise.all(\n body.events.map((event) => {\n const clamped = clampEventForStorage(event);\n return req.payload\n .create({\n collection: \"issue-events\",\n data: { ...clamped, tenant: tenant.id },\n })\n .catch(() => {\n /* swallow per-event failures; cascade guard lives in the dedup hook */\n });\n }),\n );\n\n return new Response(JSON.stringify({ accepted: body.events.length }), { status: 202 });\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Component, ReactNode, ErrorInfo } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ErrorBoundaryProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/** Optional fallback to render when an error is caught. */
|
|
6
|
+
fallback?: ReactNode;
|
|
7
|
+
/** Optional callback invoked alongside logError. */
|
|
8
|
+
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
9
|
+
}
|
|
10
|
+
interface ErrorBoundaryState {
|
|
11
|
+
hasError: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* React error boundary that forwards rendering errors to the centralized logger.
|
|
15
|
+
*
|
|
16
|
+
* Use at component-tree boundaries where you want to scope a render failure
|
|
17
|
+
* (e.g., wrapping a widget so a render error there doesn't crash the host page).
|
|
18
|
+
*/
|
|
19
|
+
declare class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
20
|
+
state: ErrorBoundaryState;
|
|
21
|
+
static getDerivedStateFromError(): ErrorBoundaryState;
|
|
22
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void;
|
|
23
|
+
render(): ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { ErrorBoundary, type ErrorBoundaryProps };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Component, ReactNode, ErrorInfo } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ErrorBoundaryProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/** Optional fallback to render when an error is caught. */
|
|
6
|
+
fallback?: ReactNode;
|
|
7
|
+
/** Optional callback invoked alongside logError. */
|
|
8
|
+
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
9
|
+
}
|
|
10
|
+
interface ErrorBoundaryState {
|
|
11
|
+
hasError: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* React error boundary that forwards rendering errors to the centralized logger.
|
|
15
|
+
*
|
|
16
|
+
* Use at component-tree boundaries where you want to scope a render failure
|
|
17
|
+
* (e.g., wrapping a widget so a render error there doesn't crash the host page).
|
|
18
|
+
*/
|
|
19
|
+
declare class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
20
|
+
state: ErrorBoundaryState;
|
|
21
|
+
static getDerivedStateFromError(): ErrorBoundaryState;
|
|
22
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void;
|
|
23
|
+
render(): ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { ErrorBoundary, type ErrorBoundaryProps };
|