@classytic/arc 2.10.3 → 2.11.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.
Files changed (153) hide show
  1. package/README.md +1 -1
  2. package/dist/{BaseController-CbKKIflT.mjs → BaseController-JNV08qOT.mjs} +595 -537
  3. package/dist/{queryCachePlugin-BKbWjgDG.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
  4. package/dist/actionPermissions-C8YYU92K.mjs +22 -0
  5. package/dist/adapters/index.d.mts +2 -2
  6. package/dist/adapters/index.mjs +1 -1
  7. package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
  8. package/dist/audit/index.d.mts +2 -2
  9. package/dist/audit/index.mjs +15 -17
  10. package/dist/auth/index.d.mts +4 -4
  11. package/dist/auth/index.mjs +3 -3
  12. package/dist/auth/redis-session.d.mts +1 -1
  13. package/dist/{betterAuthOpenApi-BBRVhjQN.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
  14. package/dist/cache/index.d.mts +3 -2
  15. package/dist/cache/index.mjs +3 -3
  16. package/dist/cli/commands/docs.mjs +2 -2
  17. package/dist/cli/commands/generate.mjs +37 -27
  18. package/dist/cli/commands/init.mjs +47 -34
  19. package/dist/cli/commands/introspect.mjs +1 -1
  20. package/dist/context/index.d.mts +58 -0
  21. package/dist/context/index.mjs +2 -0
  22. package/dist/core/index.d.mts +3 -3
  23. package/dist/core/index.mjs +4 -3
  24. package/dist/core-DXdSSFW-.mjs +1037 -0
  25. package/dist/createActionRouter-BwaSM0No.mjs +166 -0
  26. package/dist/{createApp-BuvPma24.mjs → createApp-DvNYEhpb.mjs} +118 -36
  27. package/dist/docs/index.d.mts +2 -2
  28. package/dist/docs/index.mjs +1 -1
  29. package/dist/{elevation-C7hgL_aI.mjs → elevation-DOFoxoDs.mjs} +1 -1
  30. package/dist/errorHandler-Co3lnVmJ.d.mts +114 -0
  31. package/dist/{eventPlugin-DCUjuiQT.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
  32. package/dist/{eventPlugin-CxWgpd6K.d.mts → eventPlugin-CUNjYYRY.d.mts} +1 -1
  33. package/dist/events/index.d.mts +4 -4
  34. package/dist/events/index.mjs +69 -51
  35. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  36. package/dist/events/transports/redis.d.mts +1 -1
  37. package/dist/factory/index.d.mts +1 -1
  38. package/dist/factory/index.mjs +2 -2
  39. package/dist/{fields-Lo1VUDpt.d.mts → fields-C8Y0XLAu.d.mts} +1 -1
  40. package/dist/hooks/index.d.mts +1 -1
  41. package/dist/hooks/index.mjs +1 -1
  42. package/dist/idempotency/index.d.mts +3 -3
  43. package/dist/idempotency/index.mjs +38 -27
  44. package/dist/idempotency/redis.d.mts +1 -1
  45. package/dist/{index-ChIw3776.d.mts → index-BYCqHCVu.d.mts} +4 -4
  46. package/dist/{index-Cl0uoKd5.d.mts → index-Cm0vUrr_.d.mts} +2100 -1688
  47. package/dist/{index-DStwgFUK.d.mts → index-DAushRTt.d.mts} +29 -10
  48. package/dist/index-DsJ1MNfC.d.mts +1179 -0
  49. package/dist/{index-8qw4y6ff.d.mts → index-t8pLpPFW.d.mts} +13 -10
  50. package/dist/index.d.mts +7 -251
  51. package/dist/index.mjs +8 -128
  52. package/dist/integrations/event-gateway.d.mts +2 -2
  53. package/dist/integrations/event-gateway.mjs +1 -1
  54. package/dist/integrations/index.d.mts +2 -2
  55. package/dist/integrations/mcp/index.d.mts +2 -2
  56. package/dist/integrations/mcp/index.mjs +1 -1
  57. package/dist/integrations/mcp/testing.d.mts +1 -1
  58. package/dist/integrations/mcp/testing.mjs +1 -1
  59. package/dist/integrations/streamline.d.mts +46 -5
  60. package/dist/integrations/streamline.mjs +50 -21
  61. package/dist/integrations/websocket-redis.d.mts +1 -1
  62. package/dist/integrations/websocket.d.mts +2 -154
  63. package/dist/integrations/websocket.mjs +292 -224
  64. package/dist/{keys-qcD-TVJl.mjs → keys-CARyUjiR.mjs} +2 -0
  65. package/dist/{loadResources-BAzJItAJ.mjs → loadResources-YNwKHvRA.mjs} +3 -1
  66. package/dist/logger/index.d.mts +81 -0
  67. package/dist/{logger-DLg8-Ueg.mjs → logger/index.mjs} +1 -6
  68. package/dist/middleware/index.d.mts +109 -0
  69. package/dist/middleware/index.mjs +70 -0
  70. package/dist/multipartBody-CvTR1Un6.mjs +123 -0
  71. package/dist/{openapi-B5F8AddX.mjs → openapi-C0L9ar7m.mjs} +9 -7
  72. package/dist/org/index.d.mts +2 -2
  73. package/dist/permissions/index.d.mts +2 -2
  74. package/dist/permissions/index.mjs +1 -3
  75. package/dist/{permissions-Dk6mshja.mjs → permissions-B4vU9L0Q.mjs} +220 -2
  76. package/dist/pipe-DVoIheVC.mjs +62 -0
  77. package/dist/pipeline/index.d.mts +62 -0
  78. package/dist/pipeline/index.mjs +53 -0
  79. package/dist/plugins/index.d.mts +25 -5
  80. package/dist/plugins/index.mjs +10 -10
  81. package/dist/plugins/response-cache.mjs +1 -1
  82. package/dist/plugins/tracing-entry.d.mts +1 -1
  83. package/dist/plugins/tracing-entry.mjs +42 -24
  84. package/dist/presets/filesUpload.d.mts +4 -4
  85. package/dist/presets/filesUpload.mjs +255 -1
  86. package/dist/presets/index.d.mts +1 -1
  87. package/dist/presets/index.mjs +2 -2
  88. package/dist/presets/multiTenant.d.mts +1 -1
  89. package/dist/presets/multiTenant.mjs +48 -8
  90. package/dist/presets/search.d.mts +2 -2
  91. package/dist/presets/search.mjs +1 -1
  92. package/dist/{presets-fLJVXdVn.mjs → presets-k604Lj99.mjs} +1 -1
  93. package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
  94. package/dist/{queryCachePlugin-DQCEfJis.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
  95. package/dist/{redis-DqyeggCa.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
  96. package/dist/{redis-stream-CakIQmwR.d.mts → redis-stream-CM8TXTix.d.mts} +1 -1
  97. package/dist/registry/index.d.mts +1 -1
  98. package/dist/registry/index.mjs +2 -2
  99. package/dist/{requestContext-xHIKedG6.mjs → requestContext-CfRkaxwf.mjs} +1 -1
  100. package/dist/{resourceToTools-BElv3xPT.mjs → resourceToTools--okX6QBr.mjs} +534 -415
  101. package/dist/routerShared-DeESFp4a.mjs +515 -0
  102. package/dist/schemaIR-BlG9bY7v.mjs +137 -0
  103. package/dist/scope/index.d.mts +2 -2
  104. package/dist/scope/index.mjs +1 -1
  105. package/dist/{sse-yBCgOLGu.mjs → sse-V7aXc3bW.mjs} +1 -1
  106. package/dist/{store-helpers-ZCSMJJAX.mjs → store-helpers-BhrzxvyQ.mjs} +4 -0
  107. package/dist/testing/index.d.mts +367 -711
  108. package/dist/testing/index.mjs +646 -1434
  109. package/dist/testing/storageContract.d.mts +1 -1
  110. package/dist/{tracing-65B51Dw3.d.mts → tracing-DokiEsuz.d.mts} +9 -4
  111. package/dist/types/index.d.mts +5 -5
  112. package/dist/types/index.mjs +1 -3
  113. package/dist/types/storage.d.mts +1 -1
  114. package/dist/{types-Co8k3NyS.d.mts → types-CgikqKAj.d.mts} +133 -21
  115. package/dist/{types-Btdda02s.d.mts → types-D9NqiYIw.d.mts} +1 -1
  116. package/dist/utils/index.d.mts +2 -898
  117. package/dist/utils/index.mjs +4 -5
  118. package/dist/utils-D3Yxnrwr.mjs +1639 -0
  119. package/dist/versioning-M9lNLhO8.d.mts +117 -0
  120. package/dist/websocket-CyJ1VIFI.d.mts +186 -0
  121. package/package.json +26 -8
  122. package/skills/arc/SKILL.md +124 -39
  123. package/skills/arc/references/testing.md +212 -183
  124. package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
  125. package/dist/core-CcR01lup.mjs +0 -1411
  126. package/dist/createActionRouter-Bp_5c_2b.mjs +0 -249
  127. package/dist/errorHandler-DRQ3EqfL.d.mts +0 -218
  128. package/dist/errors-CCSsMpXE.d.mts +0 -140
  129. package/dist/fields-bxkeltzz.mjs +0 -126
  130. package/dist/filesUpload-t21LS-py.mjs +0 -377
  131. package/dist/queryParser-DBqBB6AC.mjs +0 -352
  132. package/dist/types-Csi3FLfq.mjs +0 -27
  133. package/dist/utils-B2fNOD_i.mjs +0 -929
  134. /package/dist/{EventTransport-CUw5NNWe.d.mts → EventTransport-CfVEGaEl.d.mts} +0 -0
  135. /package/dist/{HookSystem-BNYKnrXF.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
  136. /package/dist/{ResourceRegistry-BPd6NQDm.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
  137. /package/dist/{caching-CBpK_SCM.mjs → caching-CheW3m-S.mjs} +0 -0
  138. /package/dist/{elevation-C5SwtkAn.d.mts → elevation-s5ykdNHr.d.mts} +0 -0
  139. /package/dist/{errorHandler-Bb49BvPD.mjs → errorHandler-BQm8ZxTK.mjs} +0 -0
  140. /package/dist/{externalPaths-BQ8QijNH.d.mts → externalPaths-Bapitwvd.d.mts} +0 -0
  141. /package/dist/{interface-CSbZdv_3.d.mts → interface-CkkWm5uR.d.mts} +0 -0
  142. /package/dist/{interface-D218ikEo.d.mts → interface-Da0r7Lna.d.mts} +0 -0
  143. /package/dist/{memory-B5Amv9A1.mjs → memory-DikHSvWa.mjs} +0 -0
  144. /package/dist/{metrics-DuhiSEZI.mjs → metrics-Csh4nsvv.mjs} +0 -0
  145. /package/dist/{pluralize-A0tWEl1K.mjs → pluralize-BneOJkpi.mjs} +0 -0
  146. /package/dist/{registry-B3lRFBWo.mjs → registry-D63ee7fl.mjs} +0 -0
  147. /package/dist/{replyHelpers-CXtJDAZ0.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
  148. /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
  149. /package/dist/{sessionManager-BkzVU8h2.d.mts → sessionManager-D-oNWHz3.d.mts} +0 -0
  150. /package/dist/{storage-CVk_SEn2.d.mts → storage-BwGQXUpd.d.mts} +0 -0
  151. /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
  152. /package/dist/{types-BD85MlEK.d.mts → types-tgR4Pt8F.d.mts} +0 -0
  153. /package/dist/{versioning-C2U_bLY0.mjs → versioning-CGPjkqAg.mjs} +0 -0
@@ -1,126 +0,0 @@
1
- //#region src/permissions/fields.ts
2
- /**
3
- * Field-Level Permissions
4
- *
5
- * Control field visibility and writability per role.
6
- * Integrated into the response path (read) and sanitization path (write).
7
- *
8
- * @example
9
- * ```typescript
10
- * import { fields, defineResource } from '@classytic/arc';
11
- *
12
- * const userResource = defineResource({
13
- * name: 'user',
14
- * adapter: userAdapter,
15
- * fields: {
16
- * salary: fields.visibleTo(['admin', 'hr']),
17
- * internalNotes: fields.writableBy(['admin']),
18
- * email: fields.redactFor(['viewer']),
19
- * password: fields.hidden(),
20
- * },
21
- * });
22
- * ```
23
- */
24
- /** Type guard for Mongoose-like documents with toObject() */
25
- function isMongooseDoc(obj) {
26
- return !!obj && typeof obj === "object" && "toObject" in obj && typeof obj.toObject === "function";
27
- }
28
- const fields = {
29
- hidden() {
30
- return { _type: "hidden" };
31
- },
32
- visibleTo(roles) {
33
- return {
34
- _type: "visibleTo",
35
- roles
36
- };
37
- },
38
- writableBy(roles) {
39
- return {
40
- _type: "writableBy",
41
- roles
42
- };
43
- },
44
- redactFor(roles, redactValue = "***") {
45
- return {
46
- _type: "redactFor",
47
- roles,
48
- redactValue
49
- };
50
- }
51
- };
52
- /**
53
- * Apply field-level READ permissions to a response object.
54
- * Strips hidden fields, enforces visibility, and applies redaction.
55
- *
56
- * @param data - The response object (mutated in place for performance)
57
- * @param fieldPermissions - Field permission map from resource config
58
- * @param userRoles - Current user's roles (empty array for unauthenticated)
59
- * @returns The filtered object
60
- */
61
- function applyFieldReadPermissions(data, fieldPermissions, userRoles) {
62
- if (!data || typeof data !== "object") return data;
63
- const result = { ...isMongooseDoc(data) ? data.toObject() : data };
64
- for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
65
- case "hidden":
66
- delete result[field];
67
- break;
68
- case "visibleTo":
69
- if (!perm.roles?.some((r) => userRoles.includes(r))) delete result[field];
70
- break;
71
- case "redactFor":
72
- if (perm.roles?.some((r) => userRoles.includes(r))) result[field] = perm.redactValue ?? "***";
73
- break;
74
- case "writableBy": break;
75
- }
76
- return result;
77
- }
78
- /**
79
- * Apply field-level WRITE permissions to request body.
80
- *
81
- * Returns both the filtered body and the list of denied fields. Callers are
82
- * expected to reject the request when `deniedFields.length > 0` — silently
83
- * stripping fields hides misconfigurations and real attacks. See
84
- * `BodySanitizer` for the default policy.
85
- *
86
- * @param body - The request body (returns a new filtered copy)
87
- * @param fieldPermissions - Field permission map from resource config
88
- * @param userRoles - Current user's roles
89
- */
90
- function applyFieldWritePermissions(body, fieldPermissions, userRoles) {
91
- const result = { ...body };
92
- const deniedFields = [];
93
- for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
94
- case "hidden":
95
- if (field in result) {
96
- deniedFields.push(field);
97
- delete result[field];
98
- }
99
- break;
100
- case "writableBy":
101
- if (field in result && !perm.roles?.some((r) => userRoles.includes(r))) {
102
- deniedFields.push(field);
103
- delete result[field];
104
- }
105
- break;
106
- }
107
- return {
108
- body: result,
109
- deniedFields
110
- };
111
- }
112
- /**
113
- * Resolve effective roles by merging global user roles with org-level roles.
114
- *
115
- * Global roles come from `req.user.role` (normalized via getUserRoles()).
116
- * Org roles come from `req.context.orgRoles` (set by BA adapter's org bridge).
117
- *
118
- * When no org context exists, returns global roles only — backward compatible.
119
- */
120
- function resolveEffectiveRoles(userRoles, orgRoles) {
121
- if (orgRoles.length === 0) return [...userRoles];
122
- if (userRoles.length === 0) return [...orgRoles];
123
- return [...new Set([...userRoles, ...orgRoles])];
124
- }
125
- //#endregion
126
- export { resolveEffectiveRoles as i, applyFieldWritePermissions as n, fields as r, applyFieldReadPermissions as t };
@@ -1,377 +0,0 @@
1
- import { o as getOrgId, p as getUserId } from "./types-AOD8fxIw.mjs";
2
- import { i as NotFoundError, u as ValidationError } from "./errors-D5c-5BJL.mjs";
3
- import { C as requireAuth, y as allowPublic } from "./permissions-Dk6mshja.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 };