@classytic/arc 2.8.4 → 2.9.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 +116 -5
- package/dist/{BaseController-DAGGc5Xn.mjs → BaseController-Vu2yc56T.mjs} +188 -102
- package/dist/EventTransport-CqZ8FyM_.d.mts +293 -0
- package/dist/adapters/index.d.mts +2 -2
- package/dist/audit/index.d.mts +100 -11
- package/dist/audit/index.mjs +71 -18
- package/dist/auth/index.d.mts +15 -7
- package/dist/auth/index.mjs +13 -6
- package/dist/{betterAuthOpenApi-C5lDyRH2.mjs → betterAuthOpenApi--rdY15Ld.mjs} +1 -1
- package/dist/cache/index.d.mts +71 -1
- package/dist/cache/index.mjs +96 -3
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/cli/commands/generate.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -5
- package/dist/{core-DKSwNSXf.mjs → core-DNncu0xF.mjs} +1 -1
- package/dist/{createActionRouter-Df1BuawX.mjs → createActionRouter-DH1YFL9m.mjs} +3 -3
- package/dist/{createApp-BOYjBgdI.mjs → createApp-CBJUJKGP.mjs} +6 -5
- package/dist/{defineResource-Bb_Bdhtw.mjs → defineResource-C__jkwvs.mjs} +22 -57
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +1 -1
- package/dist/dynamic/index.d.mts +2 -2
- package/dist/dynamic/index.mjs +3 -3
- package/dist/{elevation-BBGFjzIP.mjs → elevation-DxQ6ACbt.mjs} +20 -6
- package/dist/{errorHandler-mzqk4cGl.mjs → errorHandler-CZDW4EXS.mjs} +59 -7
- package/dist/{errorHandler-CdZDavNH.d.mts → errorHandler-DixGcttC.d.mts} +37 -2
- package/dist/{eventPlugin-CVxlE6De.d.mts → eventPlugin-BxvaCIZF.d.mts} +14 -2
- package/dist/{eventPlugin-D91S2YF4.mjs → eventPlugin-Dl7MoVWH.mjs} +83 -5
- package/dist/events/index.d.mts +147 -36
- package/dist/events/index.mjs +338 -101
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +1 -1
- package/dist/{fields-DC4So2M2.d.mts → fields-BC7zcmI9.d.mts} +15 -3
- package/dist/{fields-ipsbIRPK.mjs → fields-CU6FlaDV.mjs} +18 -5
- package/dist/filesUpload-q8oHt--L.mjs +377 -0
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +28 -4
- package/dist/idempotency/index.mjs +111 -2
- package/dist/idempotency/redis.d.mts +2 -2
- package/dist/idempotency/redis.mjs +134 -13
- package/dist/{index-CSkeivBx.d.mts → index-C-xjcA6F.d.mts} +2 -2
- package/dist/{index-CpTSDqmD.d.mts → index-Cibkchnx.d.mts} +5 -136
- package/dist/{index-BgmMdpm8.d.mts → index-CtGKT0lf.d.mts} +1 -1
- package/dist/index.d.mts +8 -8
- package/dist/index.mjs +8 -8
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/jobs.d.mts +25 -3
- package/dist/integrations/jobs.mjs +63 -4
- package/dist/integrations/mcp/index.d.mts +26 -8
- package/dist/integrations/mcp/index.mjs +96 -17
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/webhooks.d.mts +5 -0
- package/dist/integrations/webhooks.mjs +6 -0
- package/dist/{interface-BVuMfeVv.d.mts → interface-YrWsmKqE.d.mts} +324 -194
- package/dist/{openapi-CYCuekCn.mjs → openapi-CXuTG1M9.mjs} +3 -3
- package/dist/org/index.d.mts +2 -2
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +3 -3
- package/dist/{permissions-CH4cNwJi.mjs → permissions-oNZawnkR.mjs} +1 -1
- package/dist/plugins/index.d.mts +6 -6
- package/dist/plugins/index.mjs +4 -4
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/policies/index.d.mts +26 -33
- package/dist/presets/filesUpload.d.mts +71 -0
- package/dist/presets/filesUpload.mjs +2 -0
- package/dist/presets/index.d.mts +4 -2
- package/dist/presets/index.mjs +4 -2
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +1 -1
- package/dist/presets/search.d.mts +91 -0
- package/dist/presets/search.mjs +150 -0
- package/dist/{presets-C2xgzW6x.mjs → presets-hM4WhNWY.mjs} +1 -1
- package/dist/{queryCachePlugin-D0iIVhW_.mjs → queryCachePlugin-DbUVroUG.mjs} +2 -2
- package/dist/redis-MXLp1oOf.d.mts +115 -0
- package/dist/{redis-stream-D54N5oXs.d.mts → redis-stream-Bz-4q96t.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/{resourceToTools-O_HwWXFa.mjs → resourceToTools-C3cWymnW.mjs} +65 -48
- package/dist/rpc/index.mjs +1 -1
- package/dist/{schemaConverter-OxfCshus.mjs → schemaConverter-BxFDdtXu.mjs} +25 -9
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +1 -1
- package/dist/storage-BwGQXUpd.d.mts +146 -0
- package/dist/store-helpers-DFiZl5TL.mjs +57 -0
- package/dist/testing/index.d.mts +7 -15
- package/dist/testing/index.mjs +23 -76
- package/dist/testing/storageContract.d.mts +26 -0
- package/dist/testing/storageContract.mjs +216 -0
- package/dist/types/index.d.mts +5 -5
- package/dist/types/storage.d.mts +2 -0
- package/dist/types/storage.mjs +1 -0
- package/dist/{types-CcG4avic.d.mts → types-CoSzA-s-.d.mts} +1 -1
- package/dist/{types-Bg2X42_m.d.mts → types-CunEX4UX.d.mts} +7 -5
- package/dist/{types-CVC4HOKi.d.mts → types-DZi1aYhm.d.mts} +1 -1
- package/dist/utils/index.d.mts +26 -8
- package/dist/utils/index.mjs +6 -6
- package/dist/{utils-yYT3HDXt.mjs → utils-B7FuRr9w.mjs} +1 -1
- package/package.json +23 -11
- package/skills/arc/SKILL.md +92 -14
- package/skills/arc/references/auth.md +94 -0
- package/skills/arc/references/events.md +229 -12
- package/skills/arc/references/mcp.md +4 -17
- package/skills/arc/references/multi-tenancy.md +43 -0
- package/skills/arc/references/production.md +34 -19
- package/dist/EventTransport-CinyO7zQ.d.mts +0 -135
- package/dist/audit/mongodb.d.mts +0 -2
- package/dist/audit/mongodb.mjs +0 -2
- package/dist/idempotency/mongodb.d.mts +0 -2
- package/dist/idempotency/mongodb.mjs +0 -123
- package/dist/mongodb-B5O6xaW1.mjs +0 -90
- package/dist/mongodb-B8U2xaLj.d.mts +0 -127
- package/dist/mongodb-X7LbEjTN.d.mts +0 -80
- package/dist/redis-z3sFr1UP.d.mts +0 -49
- /package/dist/{applyPermissionResult-D6GPMsvh.mjs → applyPermissionResult-bqGpo9ML.mjs} +0 -0
- /package/dist/{circuitBreaker-cmi5XDv5.mjs → circuitBreaker-l18oRgL5.mjs} +0 -0
- /package/dist/{elevation-s5ykdNHr.d.mts → elevation-B6S5csVA.d.mts} +0 -0
- /package/dist/{errors-Bmn3eZT6.d.mts → errors-BI8kEKsO.d.mts} +0 -0
- /package/dist/{errors-BF2bIOIS.mjs → errors-CqWnSqM-.mjs} +0 -0
- /package/dist/{memory-Cp7_cAko.mjs → memory-BFAYkf8H.mjs} +0 -0
- /package/dist/{pluralize-A0tWEl1K.mjs → pluralize-CWP6MB39.mjs} +0 -0
- /package/dist/{queryParser-CgCtsjti.mjs → queryParser-Cs-6SHQK.mjs} +0 -0
- /package/dist/{requestContext-DYvHl113.mjs → requestContext-DYtmNpm5.mjs} +0 -0
- /package/dist/{tracing-DxjKk7eW.d.mts → tracing-xqXzWeaf.d.mts} +0 -0
- /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-Cj5Rgvlg.mjs} +0 -0
- /package/dist/{types-C72d3NDn.d.mts → types-BD85MlEK.d.mts} +0 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { o as getOrgId, p as getUserId } from "./types-AOD8fxIw.mjs";
|
|
2
|
+
import { i as NotFoundError, u as ValidationError } from "./errors-CqWnSqM-.mjs";
|
|
3
|
+
import { n as allowPublic, s as requireAuth } from "./permissions-oNZawnkR.mjs";
|
|
4
|
+
//#region src/middleware/multipartBody.ts
|
|
5
|
+
const DEFAULT_MAX_FILE_SIZE$1 = 10 * 1024 * 1024;
|
|
6
|
+
const DEFAULT_MAX_FILES = 5;
|
|
7
|
+
const DEFAULT_FILES_KEY = "_files";
|
|
8
|
+
/**
|
|
9
|
+
* Build a matcher for MIME allow-lists that supports exact (e.g. `image/png`),
|
|
10
|
+
* subtype wildcards (e.g. `image/\*`), and total wildcards (`\*` or `\*\/\*`).
|
|
11
|
+
*
|
|
12
|
+
* Returns `undefined` when no filter is needed — either because the option
|
|
13
|
+
* was omitted or because a total wildcard was present.
|
|
14
|
+
*/
|
|
15
|
+
function buildMimeMatcher(allowed) {
|
|
16
|
+
if (!allowed || allowed.length === 0) return void 0;
|
|
17
|
+
const exact = /* @__PURE__ */ new Set();
|
|
18
|
+
const prefixes = [];
|
|
19
|
+
for (const entry of allowed) {
|
|
20
|
+
const value = entry.trim().toLowerCase();
|
|
21
|
+
if (!value) continue;
|
|
22
|
+
if (value === "*" || value === "*/*") return void 0;
|
|
23
|
+
if (value.endsWith("/*")) prefixes.push(value.slice(0, -1));
|
|
24
|
+
else exact.add(value);
|
|
25
|
+
}
|
|
26
|
+
if (exact.size === 0 && prefixes.length === 0) return void 0;
|
|
27
|
+
return {
|
|
28
|
+
matches(mime) {
|
|
29
|
+
const m = mime.toLowerCase();
|
|
30
|
+
if (exact.has(m)) return true;
|
|
31
|
+
for (const p of prefixes) if (m.startsWith(p)) return true;
|
|
32
|
+
return false;
|
|
33
|
+
},
|
|
34
|
+
describe() {
|
|
35
|
+
return [...exact, ...prefixes.map((p) => `${p}*`)].join(", ");
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Create a multipart body parsing middleware.
|
|
41
|
+
*
|
|
42
|
+
* When a request has `content-type: multipart/form-data`, this middleware:
|
|
43
|
+
* 1. Reads all parts (fields + files)
|
|
44
|
+
* 2. Sets text fields on `req.body` as a plain object
|
|
45
|
+
* 3. Attaches file buffers to `req.body[filesKey]` (default: `req.body._files`)
|
|
46
|
+
*
|
|
47
|
+
* For non-multipart requests (regular JSON), this is a no-op — the request
|
|
48
|
+
* passes through unchanged. This makes it safe to add to create/update
|
|
49
|
+
* middlewares without breaking JSON clients.
|
|
50
|
+
*/
|
|
51
|
+
function multipartBody(options = {}) {
|
|
52
|
+
const maxFileSize = options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE$1;
|
|
53
|
+
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
|
54
|
+
const mimeMatcher = buildMimeMatcher(options.allowedMimeTypes);
|
|
55
|
+
const filesKey = options.filesKey ?? DEFAULT_FILES_KEY;
|
|
56
|
+
const requiredFields = options.requiredFields && options.requiredFields.length > 0 ? options.requiredFields : void 0;
|
|
57
|
+
return async function parseMultipartBody(request, reply) {
|
|
58
|
+
if (!(request.headers["content-type"] ?? "").includes("multipart/form-data")) return;
|
|
59
|
+
if (typeof request.parts !== "function") {
|
|
60
|
+
request.log.warn("multipartBody middleware: @fastify/multipart not registered. Ensure createApp() has multipart enabled (default) or install @fastify/multipart.");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const body = {};
|
|
64
|
+
const files = {};
|
|
65
|
+
let fileCount = 0;
|
|
66
|
+
try {
|
|
67
|
+
const parts = request.parts();
|
|
68
|
+
for await (const part of parts) if (part.type === "file") {
|
|
69
|
+
if (fileCount >= maxFiles) continue;
|
|
70
|
+
if (mimeMatcher && !mimeMatcher.matches(part.mimetype)) return reply.code(415).send({
|
|
71
|
+
success: false,
|
|
72
|
+
error: `File type '${part.mimetype}' not allowed. Accepted: ${mimeMatcher.describe()}`
|
|
73
|
+
});
|
|
74
|
+
const buffer = await part.toBuffer();
|
|
75
|
+
if (buffer.length > maxFileSize) return reply.code(413).send({
|
|
76
|
+
success: false,
|
|
77
|
+
error: `File '${part.filename}' exceeds maximum size of ${Math.round(maxFileSize / 1024 / 1024)}MB`
|
|
78
|
+
});
|
|
79
|
+
files[part.fieldname] = {
|
|
80
|
+
filename: part.filename,
|
|
81
|
+
mimetype: part.mimetype,
|
|
82
|
+
buffer,
|
|
83
|
+
size: buffer.length,
|
|
84
|
+
fieldname: part.fieldname
|
|
85
|
+
};
|
|
86
|
+
fileCount++;
|
|
87
|
+
} else body[part.fieldname] = tryParseValue(part.value);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
request.log.error({ err }, "multipartBody: failed to parse multipart form");
|
|
90
|
+
return reply.code(400).send({
|
|
91
|
+
success: false,
|
|
92
|
+
error: "Failed to parse multipart form data"
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (requiredFields) {
|
|
96
|
+
const missing = requiredFields.filter((name) => !(name in files));
|
|
97
|
+
if (missing.length > 0) return reply.code(400).send({
|
|
98
|
+
success: false,
|
|
99
|
+
error: `Missing required file field${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`,
|
|
100
|
+
code: "MISSING_FILE_FIELDS",
|
|
101
|
+
details: { missing }
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (fileCount > 0) body[filesKey] = files;
|
|
105
|
+
request.body = body;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Try to parse a form field value as JSON, number, or boolean.
|
|
110
|
+
* Falls back to the raw string if parsing fails.
|
|
111
|
+
*/
|
|
112
|
+
function tryParseValue(value) {
|
|
113
|
+
if (value === "true") return true;
|
|
114
|
+
if (value === "false") return false;
|
|
115
|
+
if (value === "null") return null;
|
|
116
|
+
if (/^-?\d+(\.\d+)?$/.test(value) && value.length < 16) {
|
|
117
|
+
const num = Number(value);
|
|
118
|
+
if (Number.isFinite(num)) return num;
|
|
119
|
+
}
|
|
120
|
+
if (value.startsWith("{") && value.endsWith("}") || value.startsWith("[") && value.endsWith("]")) try {
|
|
121
|
+
return JSON.parse(value);
|
|
122
|
+
} catch {}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/presets/filesUpload.ts
|
|
127
|
+
const DEFAULT_FIELD_NAME = "file";
|
|
128
|
+
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
129
|
+
function defaultContextFrom(scope) {
|
|
130
|
+
if (!scope) return {};
|
|
131
|
+
const userId = getUserId(scope);
|
|
132
|
+
const organizationId = getOrgId(scope);
|
|
133
|
+
const ctx = {};
|
|
134
|
+
if (userId !== void 0) ctx.userId = userId;
|
|
135
|
+
if (organizationId !== void 0) ctx.organizationId = organizationId;
|
|
136
|
+
return ctx;
|
|
137
|
+
}
|
|
138
|
+
function buildStorageContext(request, contextFrom) {
|
|
139
|
+
const scope = request.scope;
|
|
140
|
+
return {
|
|
141
|
+
scope: contextFrom(scope),
|
|
142
|
+
requestId: request.id
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Parse a single-range `Range: bytes=start-end` header.
|
|
147
|
+
*
|
|
148
|
+
* Returns `undefined` when the header is missing or unparseable. Only
|
|
149
|
+
* satisfiable single ranges are supported — multi-range requests fall through
|
|
150
|
+
* to the full-object response (per RFC 7233 §4.1 a server MAY ignore ranges).
|
|
151
|
+
*/
|
|
152
|
+
function parseRangeHeader(header, totalSize) {
|
|
153
|
+
if (!header || !header.startsWith("bytes=")) return void 0;
|
|
154
|
+
const spec = header.slice(6).split(",")[0]?.trim();
|
|
155
|
+
if (!spec) return void 0;
|
|
156
|
+
const dashIndex = spec.indexOf("-");
|
|
157
|
+
if (dashIndex === -1) return void 0;
|
|
158
|
+
const startRaw = spec.slice(0, dashIndex);
|
|
159
|
+
const endRaw = spec.slice(dashIndex + 1);
|
|
160
|
+
if (startRaw === "") {
|
|
161
|
+
if (totalSize === void 0) return void 0;
|
|
162
|
+
const suffix = Number(endRaw);
|
|
163
|
+
if (!Number.isFinite(suffix) || suffix <= 0) return void 0;
|
|
164
|
+
return {
|
|
165
|
+
start: Math.max(0, totalSize - suffix),
|
|
166
|
+
end: totalSize - 1
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const start = Number(startRaw);
|
|
170
|
+
if (!Number.isFinite(start) || start < 0) return void 0;
|
|
171
|
+
if (endRaw === "") {
|
|
172
|
+
if (totalSize === void 0) return void 0;
|
|
173
|
+
return {
|
|
174
|
+
start,
|
|
175
|
+
end: totalSize - 1
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const end = Number(endRaw);
|
|
179
|
+
if (!Number.isFinite(end) || end < start) return void 0;
|
|
180
|
+
if (totalSize !== void 0 && end >= totalSize) return {
|
|
181
|
+
start,
|
|
182
|
+
end: totalSize - 1
|
|
183
|
+
};
|
|
184
|
+
return {
|
|
185
|
+
start,
|
|
186
|
+
end
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Strict policy — rejects filenames that could escape a storage root or
|
|
191
|
+
* confuse a filesystem. Safe default for disk/S3 adapters that compose
|
|
192
|
+
* `${prefix}/${filename}` or `path.join(root, filename)`.
|
|
193
|
+
*/
|
|
194
|
+
function strictFilenamePolicy(filename) {
|
|
195
|
+
if (filename.length === 0) throw new ValidationError("Upload filename is empty");
|
|
196
|
+
if (filename.length > 255) throw new ValidationError("Upload filename exceeds 255 characters");
|
|
197
|
+
if (filename.includes("\0")) throw new ValidationError("Upload filename contains a NUL byte");
|
|
198
|
+
if (filename.includes("/") || filename.includes("\\")) throw new ValidationError("Upload filename contains a path separator");
|
|
199
|
+
if (filename === "." || filename === "..") throw new ValidationError("Upload filename is a path traversal component");
|
|
200
|
+
return filename;
|
|
201
|
+
}
|
|
202
|
+
/** Resolve the user-supplied policy option into a concrete validator. */
|
|
203
|
+
function resolveFilenamePolicy(policy) {
|
|
204
|
+
if (policy === void 0 || policy === true) return strictFilenamePolicy;
|
|
205
|
+
if (policy === false || policy === "*") return (f) => f;
|
|
206
|
+
if (typeof policy === "function") return (filename) => {
|
|
207
|
+
const result = policy(filename);
|
|
208
|
+
if (result === false) throw new ValidationError(`Upload filename rejected: ${filename}`);
|
|
209
|
+
if (typeof result === "string") return result;
|
|
210
|
+
return filename;
|
|
211
|
+
};
|
|
212
|
+
return strictFilenamePolicy;
|
|
213
|
+
}
|
|
214
|
+
function makeUploadHandler(deps) {
|
|
215
|
+
return async function uploadHandler(request, reply) {
|
|
216
|
+
const file = (request.body?._files)?.[deps.fieldName];
|
|
217
|
+
if (!file) throw new ValidationError(`Missing file field '${deps.fieldName}' in multipart body`);
|
|
218
|
+
const filename = deps.applyFilenamePolicy(file.filename);
|
|
219
|
+
const ctx = buildStorageContext(request, deps.contextFrom);
|
|
220
|
+
const result = await deps.storage.upload({
|
|
221
|
+
buffer: file.buffer,
|
|
222
|
+
filename,
|
|
223
|
+
mimeType: file.mimetype,
|
|
224
|
+
size: file.size
|
|
225
|
+
}, ctx);
|
|
226
|
+
return reply.code(201).send({
|
|
227
|
+
success: true,
|
|
228
|
+
data: toResponseFile(result)
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function toResponseFile(file) {
|
|
233
|
+
const payload = {
|
|
234
|
+
id: file.id,
|
|
235
|
+
url: file.url,
|
|
236
|
+
pathname: file.pathname,
|
|
237
|
+
contentType: file.contentType,
|
|
238
|
+
bytes: file.bytes
|
|
239
|
+
};
|
|
240
|
+
if (file.metadata !== void 0) payload.metadata = file.metadata;
|
|
241
|
+
return payload;
|
|
242
|
+
}
|
|
243
|
+
function makeReadHandler(deps) {
|
|
244
|
+
return async function readHandler(request, reply) {
|
|
245
|
+
const { id } = request.params;
|
|
246
|
+
const ctx = buildStorageContext(request, deps.contextFrom);
|
|
247
|
+
reply.header("Accept-Ranges", "bytes");
|
|
248
|
+
const rangeHeader = request.headers.range;
|
|
249
|
+
let result;
|
|
250
|
+
try {
|
|
251
|
+
const parsed = rangeHeader ? parseRangeHeader(rangeHeader, void 0) : void 0;
|
|
252
|
+
result = await deps.storage.read(id, ctx, parsed);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
throw toNotFound(err, "File", id);
|
|
255
|
+
}
|
|
256
|
+
if (result.kind === "buffer") return sendBuffer(reply, result, rangeHeader);
|
|
257
|
+
return sendStream(reply, result, rangeHeader);
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function sendBuffer(reply, result, rangeHeader) {
|
|
261
|
+
reply.type(result.contentType);
|
|
262
|
+
const total = result.totalBytes ?? result.buffer.length;
|
|
263
|
+
if (result.range) {
|
|
264
|
+
const { start, end } = result.range;
|
|
265
|
+
reply.code(206);
|
|
266
|
+
reply.header("Content-Range", `bytes ${start}-${end}/${total}`);
|
|
267
|
+
reply.header("Content-Length", String(result.buffer.length));
|
|
268
|
+
return reply.send(result.buffer);
|
|
269
|
+
}
|
|
270
|
+
if (rangeHeader) {
|
|
271
|
+
const parsed = parseRangeHeader(rangeHeader, total);
|
|
272
|
+
if (parsed) {
|
|
273
|
+
const slice = result.buffer.subarray(parsed.start, parsed.end + 1);
|
|
274
|
+
reply.code(206);
|
|
275
|
+
reply.header("Content-Range", `bytes ${parsed.start}-${parsed.end}/${total}`);
|
|
276
|
+
reply.header("Content-Length", String(slice.length));
|
|
277
|
+
return reply.send(slice);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
reply.header("Content-Length", String(result.buffer.length));
|
|
281
|
+
return reply.send(result.buffer);
|
|
282
|
+
}
|
|
283
|
+
function sendStream(reply, result, rangeHeader) {
|
|
284
|
+
reply.type(result.contentType);
|
|
285
|
+
if (result.range && result.bytes !== void 0) {
|
|
286
|
+
const { start, end } = result.range;
|
|
287
|
+
reply.code(206);
|
|
288
|
+
reply.header("Content-Range", `bytes ${start}-${end}/${result.bytes}`);
|
|
289
|
+
reply.header("Content-Length", String(end - start + 1));
|
|
290
|
+
} else if (result.bytes !== void 0) {
|
|
291
|
+
reply.header("Content-Length", String(result.bytes));
|
|
292
|
+
if (rangeHeader) reply.request.log.debug({ url: reply.request.url }, "filesUploadPreset: adapter returned unsliced stream for a range request — sending full object");
|
|
293
|
+
}
|
|
294
|
+
return reply.send(result.stream);
|
|
295
|
+
}
|
|
296
|
+
function makeDeleteHandler(deps) {
|
|
297
|
+
return async function deleteHandler(request, reply) {
|
|
298
|
+
const { id } = request.params;
|
|
299
|
+
const ctx = buildStorageContext(request, deps.contextFrom);
|
|
300
|
+
if (!await deps.storage.delete(id, ctx)) throw new NotFoundError("File", id);
|
|
301
|
+
return reply.code(204).send();
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function toNotFound(err, resource, id) {
|
|
305
|
+
if (err instanceof NotFoundError) return err;
|
|
306
|
+
const maybe = err;
|
|
307
|
+
if (maybe?.statusCode === 404 || maybe?.code === "NOT_FOUND") return new NotFoundError(resource, id);
|
|
308
|
+
if (typeof maybe?.message === "string" && /not\s*found/i.test(maybe.message)) return new NotFoundError(resource, id);
|
|
309
|
+
return err;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Create a files-upload preset bound to a `Storage` adapter.
|
|
313
|
+
*
|
|
314
|
+
* The preset uses `raw: true` routes so binary responses bypass arc's JSON
|
|
315
|
+
* envelope. Upload still returns the standard `{ success: true, data }`
|
|
316
|
+
* envelope manually because the response is structured metadata, not bytes.
|
|
317
|
+
*/
|
|
318
|
+
function filesUploadPreset(options) {
|
|
319
|
+
if (!options?.storage) throw new Error("filesUploadPreset: `storage` is required");
|
|
320
|
+
const deps = {
|
|
321
|
+
storage: options.storage,
|
|
322
|
+
fieldName: options.fieldName ?? DEFAULT_FIELD_NAME,
|
|
323
|
+
contextFrom: options.contextFrom ?? defaultContextFrom,
|
|
324
|
+
applyFilenamePolicy: resolveFilenamePolicy(options.sanitizeFilename)
|
|
325
|
+
};
|
|
326
|
+
const maxFileSize = options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
327
|
+
const allowedMimeTypes = options.allowedMimeTypes;
|
|
328
|
+
const includeRoutes = {
|
|
329
|
+
upload: options.includeRoutes?.upload ?? true,
|
|
330
|
+
read: options.includeRoutes?.read ?? true,
|
|
331
|
+
delete: options.includeRoutes?.delete ?? true
|
|
332
|
+
};
|
|
333
|
+
return {
|
|
334
|
+
name: "filesUpload",
|
|
335
|
+
routes: (permissions) => {
|
|
336
|
+
const routes = [];
|
|
337
|
+
if (includeRoutes.upload) routes.push({
|
|
338
|
+
method: "POST",
|
|
339
|
+
path: "/upload",
|
|
340
|
+
operation: "filesUpload.upload",
|
|
341
|
+
summary: "Upload a file",
|
|
342
|
+
description: "Accepts a multipart/form-data request and persists the bytes via the configured Storage adapter.",
|
|
343
|
+
permissions: options.permissions?.upload ?? permissions.create ?? requireAuth(),
|
|
344
|
+
preHandler: [multipartBody({
|
|
345
|
+
maxFileSize,
|
|
346
|
+
allowedMimeTypes,
|
|
347
|
+
requiredFields: [deps.fieldName]
|
|
348
|
+
})],
|
|
349
|
+
raw: true,
|
|
350
|
+
handler: makeUploadHandler(deps)
|
|
351
|
+
});
|
|
352
|
+
if (includeRoutes.read) routes.push({
|
|
353
|
+
method: "GET",
|
|
354
|
+
path: "/:id",
|
|
355
|
+
operation: "filesUpload.read",
|
|
356
|
+
summary: "Download a file",
|
|
357
|
+
description: "Streams the stored bytes. Supports single-range `Range: bytes=start-end`.",
|
|
358
|
+
permissions: options.permissions?.read ?? permissions.get ?? allowPublic(),
|
|
359
|
+
raw: true,
|
|
360
|
+
handler: makeReadHandler(deps),
|
|
361
|
+
mcp: false
|
|
362
|
+
});
|
|
363
|
+
if (includeRoutes.delete) routes.push({
|
|
364
|
+
method: "DELETE",
|
|
365
|
+
path: "/:id",
|
|
366
|
+
operation: "filesUpload.delete",
|
|
367
|
+
summary: "Delete a file",
|
|
368
|
+
permissions: options.permissions?.delete ?? permissions.delete ?? requireAuth(),
|
|
369
|
+
raw: true,
|
|
370
|
+
handler: makeDeleteHandler(deps)
|
|
371
|
+
});
|
|
372
|
+
return routes;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
//#endregion
|
|
377
|
+
export { filesUploadPreset as t };
|
package/dist/hooks/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { An as beforeCreate, Cn as HookPhase, Dn as afterCreate, En as HookSystemOptions, Mn as beforeUpdate, Nn as createHookSystem, On as afterDelete, Pn as defineHook, Sn as HookOperation, Tn as HookSystem, bn as HookContext, jn as beforeDelete, kn as afterUpdate, wn as HookRegistration, xn as HookHandler, yn as DefineHookOptions } from "../interface-
|
|
1
|
+
import { An as beforeCreate, Cn as HookPhase, Dn as afterCreate, En as HookSystemOptions, Mn as beforeUpdate, Nn as createHookSystem, On as afterDelete, Pn as defineHook, Sn as HookOperation, Tn as HookSystem, bn as HookContext, jn as beforeDelete, kn as afterUpdate, wn as HookRegistration, xn as HookHandler, yn as DefineHookOptions } from "../interface-YrWsmKqE.mjs";
|
|
2
2
|
export { type DefineHookOptions, type HookContext, type HookHandler, type HookOperation, type HookPhase, type HookRegistration, HookSystem, type HookSystemOptions, afterCreate, afterDelete, afterUpdate, beforeCreate, beforeDelete, beforeUpdate, createHookSystem, defineHook };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { o as RepositoryLike } from "../interface-YrWsmKqE.mjs";
|
|
1
2
|
import { i as createIdempotencyResult, n as IdempotencyResult, r as IdempotencyStore, t as IdempotencyLock } from "../interface-B-pe8fhj.mjs";
|
|
2
|
-
import { n as
|
|
3
|
-
import { r as RedisIdempotencyStoreOptions, t as RedisClient } from "../redis-z3sFr1UP.mjs";
|
|
3
|
+
import { i as RedisIdempotencyStoreOptions, n as RedisClient } from "../redis-MXLp1oOf.mjs";
|
|
4
4
|
import { FastifyPluginAsync } from "fastify";
|
|
5
5
|
|
|
6
6
|
//#region src/idempotency/idempotencyPlugin.d.ts
|
|
@@ -19,10 +19,34 @@ interface IdempotencyPluginOptions {
|
|
|
19
19
|
include?: RegExp[];
|
|
20
20
|
/** URL patterns to exclude (regex). Excluded patterns take precedence */
|
|
21
21
|
exclude?: RegExp[];
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Repository managing the idempotency collection. Arc consumes it directly
|
|
24
|
+
* — no wrapper classes. Requires `getOne`, `deleteMany`, and
|
|
25
|
+
* `findOneAndUpdate` (mongokit ≥3.8 implements all three). Pass any
|
|
26
|
+
* `RepositoryLike` that matches.
|
|
27
|
+
*
|
|
28
|
+
* Use `store` (below) when your backend isn't a repository (Redis, memory
|
|
29
|
+
* for tests, custom). `repository` takes precedence when both are passed.
|
|
30
|
+
*/
|
|
31
|
+
repository?: RepositoryLike;
|
|
32
|
+
/**
|
|
33
|
+
* Non-repository store. Use for Redis (the canonical multi-instance
|
|
34
|
+
* backend when you don't already have a DB repository), memory (tests),
|
|
35
|
+
* or custom implementations of `IdempotencyStore`.
|
|
36
|
+
*
|
|
37
|
+
* Default: `MemoryIdempotencyStore`.
|
|
38
|
+
*/
|
|
23
39
|
store?: IdempotencyStore;
|
|
24
40
|
/** Retry-After header value in seconds when request is in-flight (default: 1) */
|
|
25
41
|
retryAfterSeconds?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Namespace key folded into the fingerprint — use when two deployments share
|
|
44
|
+
* a single store but should not replay each other's responses (e.g. `api`
|
|
45
|
+
* vs `jobs` with the same Redis, or prod vs canary sharing one cluster).
|
|
46
|
+
*
|
|
47
|
+
* Omit for the common case where the store is per-deployment.
|
|
48
|
+
*/
|
|
49
|
+
namespace?: string;
|
|
26
50
|
}
|
|
27
51
|
declare module "fastify" {
|
|
28
52
|
interface FastifyRequest {
|
|
@@ -93,4 +117,4 @@ declare class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
93
117
|
private evictOldest;
|
|
94
118
|
}
|
|
95
119
|
//#endregion
|
|
96
|
-
export { type IdempotencyLock, type IdempotencyPluginOptions, type IdempotencyResult, type IdempotencyStore, MemoryIdempotencyStore, type MemoryIdempotencyStoreOptions, type
|
|
120
|
+
export { type IdempotencyLock, type IdempotencyPluginOptions, type IdempotencyResult, type IdempotencyStore, MemoryIdempotencyStore, type MemoryIdempotencyStoreOptions, type RedisClient, type RedisIdempotencyStoreOptions, createIdempotencyResult, _default as idempotencyPlugin, idempotencyPlugin as idempotencyPluginFn };
|
|
@@ -1,5 +1,113 @@
|
|
|
1
|
+
import { n as createSafeGetOne, t as createIsDuplicateKeyError } from "../store-helpers-DFiZl5TL.mjs";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import fp from "fastify-plugin";
|
|
4
|
+
//#region src/idempotency/repository-idempotency-adapter.ts
|
|
5
|
+
function repositoryAsIdempotencyStore(repository, defaultTtlMs) {
|
|
6
|
+
const missing = [];
|
|
7
|
+
if (typeof repository.getOne !== "function") missing.push("getOne");
|
|
8
|
+
if (typeof repository.deleteMany !== "function") missing.push("deleteMany");
|
|
9
|
+
if (typeof repository.findOneAndUpdate !== "function") missing.push("findOneAndUpdate");
|
|
10
|
+
if (missing.length > 0) throw new Error(`idempotencyPlugin: repository is missing required methods: ${missing.join(", ")}. mongokit ≥3.8 satisfies these; other kits must implement them to back idempotency via a repository.`);
|
|
11
|
+
const r = repository;
|
|
12
|
+
const isDuplicateKeyError = createIsDuplicateKeyError(repository);
|
|
13
|
+
const safeGetOne = createSafeGetOne(repository);
|
|
14
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15
|
+
return {
|
|
16
|
+
name: "repository",
|
|
17
|
+
async get(key) {
|
|
18
|
+
const doc = await safeGetOne({ _id: key });
|
|
19
|
+
if (!doc?.result) return void 0;
|
|
20
|
+
if (new Date(doc.expiresAt) < /* @__PURE__ */ new Date()) return void 0;
|
|
21
|
+
return {
|
|
22
|
+
key,
|
|
23
|
+
statusCode: doc.result.statusCode,
|
|
24
|
+
headers: doc.result.headers,
|
|
25
|
+
body: doc.result.body,
|
|
26
|
+
createdAt: new Date(doc.createdAt),
|
|
27
|
+
expiresAt: new Date(doc.expiresAt)
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
async set(key, result) {
|
|
31
|
+
await r.findOneAndUpdate({ _id: key }, {
|
|
32
|
+
$set: {
|
|
33
|
+
result: {
|
|
34
|
+
statusCode: result.statusCode,
|
|
35
|
+
headers: result.headers,
|
|
36
|
+
body: result.body
|
|
37
|
+
},
|
|
38
|
+
createdAt: result.createdAt,
|
|
39
|
+
expiresAt: result.expiresAt
|
|
40
|
+
},
|
|
41
|
+
$unset: { lock: "" }
|
|
42
|
+
}, {
|
|
43
|
+
upsert: true,
|
|
44
|
+
returnDocument: "after"
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
async tryLock(key, requestId, ttlMs) {
|
|
48
|
+
const now = /* @__PURE__ */ new Date();
|
|
49
|
+
const lockExpiresAt = new Date(now.getTime() + ttlMs);
|
|
50
|
+
const docExpiresAt = new Date(now.getTime() + defaultTtlMs);
|
|
51
|
+
try {
|
|
52
|
+
const doc = await r.findOneAndUpdate({
|
|
53
|
+
_id: key,
|
|
54
|
+
$or: [{ lock: { $exists: false } }, { "lock.expiresAt": { $lt: now } }]
|
|
55
|
+
}, {
|
|
56
|
+
$set: { lock: {
|
|
57
|
+
requestId,
|
|
58
|
+
expiresAt: lockExpiresAt
|
|
59
|
+
} },
|
|
60
|
+
$setOnInsert: {
|
|
61
|
+
createdAt: now,
|
|
62
|
+
expiresAt: docExpiresAt
|
|
63
|
+
}
|
|
64
|
+
}, {
|
|
65
|
+
upsert: true,
|
|
66
|
+
returnDocument: "after"
|
|
67
|
+
});
|
|
68
|
+
return doc !== null && doc !== void 0;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (isDuplicateKeyError(err)) return false;
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
async unlock(key, requestId) {
|
|
75
|
+
await r.findOneAndUpdate({
|
|
76
|
+
_id: key,
|
|
77
|
+
"lock.requestId": requestId
|
|
78
|
+
}, { $unset: { lock: "" } });
|
|
79
|
+
},
|
|
80
|
+
async isLocked(key) {
|
|
81
|
+
const doc = await safeGetOne({ _id: key });
|
|
82
|
+
if (!doc?.lock) return false;
|
|
83
|
+
return new Date(doc.lock.expiresAt) > /* @__PURE__ */ new Date();
|
|
84
|
+
},
|
|
85
|
+
async delete(key) {
|
|
86
|
+
await r.deleteMany({ _id: key });
|
|
87
|
+
},
|
|
88
|
+
async deleteByPrefix(prefix) {
|
|
89
|
+
return (await r.deleteMany({ _id: { $regex: `^${escapeRegex(prefix)}` } })).deletedCount ?? 0;
|
|
90
|
+
},
|
|
91
|
+
async findByPrefix(prefix) {
|
|
92
|
+
const doc = await safeGetOne({
|
|
93
|
+
_id: { $regex: `^${escapeRegex(prefix)}` },
|
|
94
|
+
result: { $exists: true },
|
|
95
|
+
expiresAt: { $gt: /* @__PURE__ */ new Date() }
|
|
96
|
+
});
|
|
97
|
+
if (!doc?.result) return void 0;
|
|
98
|
+
return {
|
|
99
|
+
key: doc._id,
|
|
100
|
+
statusCode: doc.result.statusCode,
|
|
101
|
+
headers: doc.result.headers,
|
|
102
|
+
body: doc.result.body,
|
|
103
|
+
createdAt: new Date(doc.createdAt),
|
|
104
|
+
expiresAt: new Date(doc.expiresAt)
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
async close() {}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
3
111
|
//#region src/idempotency/stores/interface.ts
|
|
4
112
|
/**
|
|
5
113
|
* Helper to create a result object
|
|
@@ -171,7 +279,8 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
|
|
|
171
279
|
"POST",
|
|
172
280
|
"PUT",
|
|
173
281
|
"PATCH"
|
|
174
|
-
], include, exclude, store
|
|
282
|
+
], include, exclude, repository, store: explicitStore, retryAfterSeconds = 1, namespace } = opts;
|
|
283
|
+
const store = repository ? repositoryAsIdempotencyStore(repository, ttlMs) : explicitStore ?? new MemoryIdempotencyStore({ ttlMs });
|
|
175
284
|
if (!enabled) {
|
|
176
285
|
fastify.decorate("idempotency", {
|
|
177
286
|
invalidate: async () => {},
|
|
@@ -225,7 +334,7 @@ const idempotencyPlugin = async (fastify, opts = {}) => {
|
|
|
225
334
|
}
|
|
226
335
|
const user = request.user;
|
|
227
336
|
const userId = user?.id ?? user?._id ?? "anon";
|
|
228
|
-
return `${request.method}:${request.url}:${bodyHash}:u=${userId}`;
|
|
337
|
+
return `${namespace ? `n=${namespace}:` : ""}${request.method}:${request.url}:${bodyHash}:u=${userId}`;
|
|
229
338
|
}
|
|
230
339
|
const idempotencyMiddleware = async (request, reply) => {
|
|
231
340
|
if (!shouldApplyIdempotency(request)) return;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as
|
|
2
|
-
export { type RedisClient, RedisIdempotencyStore, type RedisIdempotencyStoreOptions };
|
|
1
|
+
import { a as UpstashRedisLike, i as RedisIdempotencyStoreOptions, n as RedisClient, o as ioredisAsIdempotencyClient, r as RedisIdempotencyStore, s as upstashAsIdempotencyClient, t as IoredisLike } from "../redis-MXLp1oOf.mjs";
|
|
2
|
+
export { type IoredisLike, type RedisClient, RedisIdempotencyStore, type RedisIdempotencyStoreOptions, type UpstashRedisLike, ioredisAsIdempotencyClient, upstashAsIdempotencyClient };
|