@uploadista/server 0.0.18-beta.7 → 0.0.18-beta.9
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/dist/index.cjs +2 -2
- package/dist/index.d.cts +578 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +578 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -9
- package/src/core/http-handlers/dlq-http-handlers.ts +42 -0
- package/src/core/http-handlers/flow-http-handlers.ts +61 -6
- package/src/core/http-handlers/health-http-handlers.ts +16 -0
- package/src/core/http-handlers/upload-http-handlers.ts +56 -3
- package/src/core/server.ts +10 -2
- package/src/core/types.ts +35 -0
- package/src/index.ts +2 -0
- package/src/permissions/errors.ts +105 -0
- package/src/permissions/index.ts +9 -0
- package/src/permissions/matcher.ts +139 -0
- package/src/permissions/types.ts +151 -0
- package/src/service.ts +101 -3
- package/src/usage-hooks/index.ts +8 -0
- package/src/usage-hooks/service.ts +162 -0
- package/src/usage-hooks/types.ts +221 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uploadista/server",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.18-beta.
|
|
4
|
+
"version": "0.0.18-beta.9",
|
|
5
5
|
"description": "Core Server package for Uploadista",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Uploadista",
|
|
@@ -20,23 +20,23 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@uploadista/core": "0.0.18-beta.
|
|
24
|
-
"@uploadista/observability": "0.0.18-beta.
|
|
25
|
-
"@uploadista/event-emitter-websocket": "0.0.18-beta.
|
|
26
|
-
"@uploadista/event-broadcaster-memory": "0.0.18-beta.
|
|
23
|
+
"@uploadista/core": "0.0.18-beta.9",
|
|
24
|
+
"@uploadista/observability": "0.0.18-beta.9",
|
|
25
|
+
"@uploadista/event-emitter-websocket": "0.0.18-beta.9",
|
|
26
|
+
"@uploadista/event-broadcaster-memory": "0.0.18-beta.9"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@cloudflare/workers-types": "4.
|
|
29
|
+
"@cloudflare/workers-types": "4.20251202.0",
|
|
30
30
|
"@effect/vitest": "0.27.0",
|
|
31
31
|
"@types/express": "^5.0.0",
|
|
32
32
|
"@types/node": "24.10.1",
|
|
33
|
-
"effect": "3.19.
|
|
33
|
+
"effect": "3.19.8",
|
|
34
34
|
"tsd": "0.33.0",
|
|
35
|
-
"tsdown": "0.16.
|
|
35
|
+
"tsdown": "0.16.8",
|
|
36
36
|
"typescript": "5.9.3",
|
|
37
37
|
"vitest": "4.0.14",
|
|
38
38
|
"zod": "4.1.13",
|
|
39
|
-
"@uploadista/typescript-config": "0.0.18-beta.
|
|
39
|
+
"@uploadista/typescript-config": "0.0.18-beta.9"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"effect": "^3.0.0",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { DeadLetterQueueService } from "@uploadista/core/flow";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
|
+
import { PERMISSIONS } from "../../permissions/types";
|
|
4
|
+
import { AuthContextService } from "../../service";
|
|
3
5
|
import type {
|
|
4
6
|
DlqCleanupRequest,
|
|
5
7
|
DlqCleanupResponse,
|
|
@@ -24,6 +26,11 @@ import type {
|
|
|
24
26
|
*/
|
|
25
27
|
export const handleDlqList = (req: DlqListRequest) =>
|
|
26
28
|
Effect.gen(function* () {
|
|
29
|
+
const authService = yield* AuthContextService;
|
|
30
|
+
|
|
31
|
+
// Check permission for reading DLQ
|
|
32
|
+
yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_READ);
|
|
33
|
+
|
|
27
34
|
const dlq = yield* DeadLetterQueueService;
|
|
28
35
|
const result = yield* dlq.list(req.options);
|
|
29
36
|
|
|
@@ -40,6 +47,11 @@ export const handleDlqList = (req: DlqListRequest) =>
|
|
|
40
47
|
*/
|
|
41
48
|
export const handleDlqGet = (req: DlqGetRequest) =>
|
|
42
49
|
Effect.gen(function* () {
|
|
50
|
+
const authService = yield* AuthContextService;
|
|
51
|
+
|
|
52
|
+
// Check permission for reading DLQ
|
|
53
|
+
yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_READ);
|
|
54
|
+
|
|
43
55
|
const dlq = yield* DeadLetterQueueService;
|
|
44
56
|
const item = yield* dlq.get(req.itemId);
|
|
45
57
|
|
|
@@ -56,6 +68,11 @@ export const handleDlqGet = (req: DlqGetRequest) =>
|
|
|
56
68
|
*/
|
|
57
69
|
export const handleDlqRetry = (req: DlqRetryRequest) =>
|
|
58
70
|
Effect.gen(function* () {
|
|
71
|
+
const authService = yield* AuthContextService;
|
|
72
|
+
|
|
73
|
+
// Check permission for writing to DLQ
|
|
74
|
+
yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_WRITE);
|
|
75
|
+
|
|
59
76
|
const dlq = yield* DeadLetterQueueService;
|
|
60
77
|
|
|
61
78
|
// Mark item as retrying
|
|
@@ -79,6 +96,11 @@ export const handleDlqRetry = (req: DlqRetryRequest) =>
|
|
|
79
96
|
*/
|
|
80
97
|
export const handleDlqRetryAll = (req: DlqRetryAllRequest) =>
|
|
81
98
|
Effect.gen(function* () {
|
|
99
|
+
const authService = yield* AuthContextService;
|
|
100
|
+
|
|
101
|
+
// Check permission for writing to DLQ
|
|
102
|
+
yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_WRITE);
|
|
103
|
+
|
|
82
104
|
const dlq = yield* DeadLetterQueueService;
|
|
83
105
|
|
|
84
106
|
// List items matching the filter
|
|
@@ -117,6 +139,11 @@ export const handleDlqRetryAll = (req: DlqRetryAllRequest) =>
|
|
|
117
139
|
*/
|
|
118
140
|
export const handleDlqDelete = (req: DlqDeleteRequest) =>
|
|
119
141
|
Effect.gen(function* () {
|
|
142
|
+
const authService = yield* AuthContextService;
|
|
143
|
+
|
|
144
|
+
// Check permission for writing to DLQ
|
|
145
|
+
yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_WRITE);
|
|
146
|
+
|
|
120
147
|
const dlq = yield* DeadLetterQueueService;
|
|
121
148
|
yield* dlq.delete(req.itemId);
|
|
122
149
|
|
|
@@ -133,6 +160,11 @@ export const handleDlqDelete = (req: DlqDeleteRequest) =>
|
|
|
133
160
|
*/
|
|
134
161
|
export const handleDlqResolve = (req: DlqResolveRequest) =>
|
|
135
162
|
Effect.gen(function* () {
|
|
163
|
+
const authService = yield* AuthContextService;
|
|
164
|
+
|
|
165
|
+
// Check permission for writing to DLQ
|
|
166
|
+
yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_WRITE);
|
|
167
|
+
|
|
136
168
|
const dlq = yield* DeadLetterQueueService;
|
|
137
169
|
const item = yield* dlq.markResolved(req.itemId);
|
|
138
170
|
|
|
@@ -149,6 +181,11 @@ export const handleDlqResolve = (req: DlqResolveRequest) =>
|
|
|
149
181
|
*/
|
|
150
182
|
export const handleDlqCleanup = (req: DlqCleanupRequest) =>
|
|
151
183
|
Effect.gen(function* () {
|
|
184
|
+
const authService = yield* AuthContextService;
|
|
185
|
+
|
|
186
|
+
// Check permission for writing to DLQ
|
|
187
|
+
yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_WRITE);
|
|
188
|
+
|
|
152
189
|
const dlq = yield* DeadLetterQueueService;
|
|
153
190
|
const result = yield* dlq.cleanup(req.options);
|
|
154
191
|
|
|
@@ -165,6 +202,11 @@ export const handleDlqCleanup = (req: DlqCleanupRequest) =>
|
|
|
165
202
|
*/
|
|
166
203
|
export const handleDlqStats = (_req: DlqStatsRequest) =>
|
|
167
204
|
Effect.gen(function* () {
|
|
205
|
+
const authService = yield* AuthContextService;
|
|
206
|
+
|
|
207
|
+
// Check permission for reading DLQ
|
|
208
|
+
yield* authService.requirePermission(PERMISSIONS.ENGINE.DLQ_READ);
|
|
209
|
+
|
|
168
210
|
const dlq = yield* DeadLetterQueueService;
|
|
169
211
|
const stats = yield* dlq.getStats();
|
|
170
212
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { FlowServer } from "@uploadista/core/flow";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { AuthCacheService } from "../../cache";
|
|
4
|
+
import { PERMISSIONS } from "../../permissions/types";
|
|
5
|
+
import { QuotaExceededError } from "../../permissions/errors";
|
|
4
6
|
import { AuthContextService } from "../../service";
|
|
7
|
+
import { UsageHookService } from "../../usage-hooks/service";
|
|
5
8
|
import type {
|
|
6
9
|
CancelFlowRequest,
|
|
7
10
|
CancelFlowResponse,
|
|
@@ -20,10 +23,12 @@ import type {
|
|
|
20
23
|
export const handleGetFlow = ({ flowId }: GetFlowRequest) => {
|
|
21
24
|
return Effect.gen(function* () {
|
|
22
25
|
const flowServer = yield* FlowServer;
|
|
23
|
-
// Access auth context if available
|
|
24
26
|
const authService = yield* AuthContextService;
|
|
25
27
|
const clientId = yield* authService.getClientId();
|
|
26
28
|
|
|
29
|
+
// Check permission for reading flow status
|
|
30
|
+
yield* authService.requirePermission(PERMISSIONS.FLOW.STATUS);
|
|
31
|
+
|
|
27
32
|
if (clientId) {
|
|
28
33
|
yield* Effect.logInfo(
|
|
29
34
|
`[Flow] Getting flow data: ${flowId}, client: ${clientId}`,
|
|
@@ -46,11 +51,14 @@ export const handleRunFlow = <TRequirements>({
|
|
|
46
51
|
}: RunFlowRequest) => {
|
|
47
52
|
return Effect.gen(function* () {
|
|
48
53
|
const flowServer = yield* FlowServer;
|
|
49
|
-
// Access auth context if available
|
|
50
54
|
const authService = yield* AuthContextService;
|
|
51
55
|
const authCache = yield* AuthCacheService;
|
|
56
|
+
const usageHookService = yield* UsageHookService;
|
|
52
57
|
const clientId = yield* authService.getClientId();
|
|
53
58
|
|
|
59
|
+
// Check permission for executing flows
|
|
60
|
+
yield* authService.requirePermission(PERMISSIONS.FLOW.EXECUTE);
|
|
61
|
+
|
|
54
62
|
if (clientId) {
|
|
55
63
|
yield* Effect.logInfo(
|
|
56
64
|
`[Flow] Executing flow: ${flowId}, storage: ${storageId}, client: ${clientId}`,
|
|
@@ -65,7 +73,28 @@ export const handleRunFlow = <TRequirements>({
|
|
|
65
73
|
);
|
|
66
74
|
}
|
|
67
75
|
|
|
76
|
+
// Execute onFlowStart hook for quota checking
|
|
77
|
+
if (clientId) {
|
|
78
|
+
const hookResult = yield* usageHookService.onFlowStart({
|
|
79
|
+
clientId,
|
|
80
|
+
operation: "flow",
|
|
81
|
+
metadata: {
|
|
82
|
+
flowId,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (hookResult.action === "abort") {
|
|
87
|
+
return yield* Effect.fail(
|
|
88
|
+
new QuotaExceededError(
|
|
89
|
+
hookResult.reason,
|
|
90
|
+
hookResult.code ?? "SUBSCRIPTION_REQUIRED",
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
68
96
|
// Run flow returns immediately with jobId
|
|
97
|
+
const startTime = Date.now();
|
|
69
98
|
yield* Effect.logInfo(`[Flow] Calling flowServer.runFlow...`);
|
|
70
99
|
const result = yield* flowServer
|
|
71
100
|
.runFlow<TRequirements>({
|
|
@@ -91,6 +120,10 @@ export const handleRunFlow = <TRequirements>({
|
|
|
91
120
|
|
|
92
121
|
yield* Effect.logInfo(`[Flow] Flow started with jobId: ${result.id}`);
|
|
93
122
|
|
|
123
|
+
// Note: onFlowComplete hook is called when the flow actually completes,
|
|
124
|
+
// which happens asynchronously. The hook should be invoked from the
|
|
125
|
+
// flow execution pipeline, not here where we just start the flow.
|
|
126
|
+
|
|
94
127
|
return {
|
|
95
128
|
status: 200,
|
|
96
129
|
body: result,
|
|
@@ -101,11 +134,13 @@ export const handleRunFlow = <TRequirements>({
|
|
|
101
134
|
export const handleJobStatus = ({ jobId }: GetJobStatusRequest) => {
|
|
102
135
|
return Effect.gen(function* () {
|
|
103
136
|
const flowServer = yield* FlowServer;
|
|
104
|
-
// Access auth context if available
|
|
105
137
|
const authService = yield* AuthContextService;
|
|
106
138
|
const authCache = yield* AuthCacheService;
|
|
107
139
|
const clientId = yield* authService.getClientId();
|
|
108
140
|
|
|
141
|
+
// Check permission for checking flow status
|
|
142
|
+
yield* authService.requirePermission(PERMISSIONS.FLOW.STATUS);
|
|
143
|
+
|
|
109
144
|
if (!jobId) {
|
|
110
145
|
throw new Error("No job id");
|
|
111
146
|
}
|
|
@@ -142,10 +177,12 @@ export const handleResumeFlow = <TRequirements>({
|
|
|
142
177
|
}: ResumeFlowRequest) => {
|
|
143
178
|
return Effect.gen(function* () {
|
|
144
179
|
const flowServer = yield* FlowServer;
|
|
145
|
-
// Try to get auth from current request or cached auth
|
|
146
180
|
const authService = yield* AuthContextService;
|
|
147
181
|
const authCache = yield* AuthCacheService;
|
|
148
182
|
|
|
183
|
+
// Check permission for executing flows (resume is part of execution)
|
|
184
|
+
yield* authService.requirePermission(PERMISSIONS.FLOW.EXECUTE);
|
|
185
|
+
|
|
149
186
|
// Try current auth first, fallback to cached auth
|
|
150
187
|
let clientId = yield* authService.getClientId();
|
|
151
188
|
if (!clientId) {
|
|
@@ -186,13 +223,16 @@ export const handleResumeFlow = <TRequirements>({
|
|
|
186
223
|
} as ResumeFlowResponse;
|
|
187
224
|
});
|
|
188
225
|
};
|
|
226
|
+
|
|
189
227
|
export const handlePauseFlow = ({ jobId }: PauseFlowRequest) => {
|
|
190
228
|
return Effect.gen(function* () {
|
|
191
229
|
const flowServer = yield* FlowServer;
|
|
192
|
-
// Try to get auth from current request or cached auth
|
|
193
230
|
const authService = yield* AuthContextService;
|
|
194
231
|
const authCache = yield* AuthCacheService;
|
|
195
232
|
|
|
233
|
+
// Check permission for cancelling flows (pause is related to cancel)
|
|
234
|
+
yield* authService.requirePermission(PERMISSIONS.FLOW.CANCEL);
|
|
235
|
+
|
|
196
236
|
// Try current auth first, fallback to cached auth
|
|
197
237
|
let clientId = yield* authService.getClientId();
|
|
198
238
|
if (!clientId) {
|
|
@@ -224,9 +264,12 @@ export const handlePauseFlow = ({ jobId }: PauseFlowRequest) => {
|
|
|
224
264
|
export const handleCancelFlow = ({ jobId }: CancelFlowRequest) => {
|
|
225
265
|
return Effect.gen(function* () {
|
|
226
266
|
const flowServer = yield* FlowServer;
|
|
227
|
-
// Try to get auth from current request or cached auth
|
|
228
267
|
const authService = yield* AuthContextService;
|
|
229
268
|
const authCache = yield* AuthCacheService;
|
|
269
|
+
const usageHookService = yield* UsageHookService;
|
|
270
|
+
|
|
271
|
+
// Check permission for cancelling flows
|
|
272
|
+
yield* authService.requirePermission(PERMISSIONS.FLOW.CANCEL);
|
|
230
273
|
|
|
231
274
|
if (!jobId) {
|
|
232
275
|
throw new Error("No job id");
|
|
@@ -253,6 +296,18 @@ export const handleCancelFlow = ({ jobId }: CancelFlowRequest) => {
|
|
|
253
296
|
yield* Effect.logInfo(
|
|
254
297
|
`[Flow] Flow cancelled, cleared auth cache: ${jobId}`,
|
|
255
298
|
);
|
|
299
|
+
|
|
300
|
+
// Execute onFlowComplete hook for cancelled flow
|
|
301
|
+
yield* Effect.forkDaemon(
|
|
302
|
+
usageHookService.onFlowComplete({
|
|
303
|
+
clientId,
|
|
304
|
+
operation: "flow",
|
|
305
|
+
metadata: {
|
|
306
|
+
jobId,
|
|
307
|
+
status: "cancelled",
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
256
311
|
}
|
|
257
312
|
|
|
258
313
|
return {
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
type HealthCheckConfig,
|
|
16
16
|
} from "@uploadista/core/types";
|
|
17
17
|
import { Effect } from "effect";
|
|
18
|
+
import { PERMISSIONS } from "../../permissions/types";
|
|
19
|
+
import { AuthContextService } from "../../service";
|
|
18
20
|
import {
|
|
19
21
|
createLivenessResponse,
|
|
20
22
|
performComponentsCheck,
|
|
@@ -66,12 +68,19 @@ export const handleHealthLiveness = (
|
|
|
66
68
|
* Checks all critical dependencies (storage, KV store) and returns:
|
|
67
69
|
* - 200 OK if all dependencies are healthy
|
|
68
70
|
* - 503 Service Unavailable if any critical dependency is unavailable
|
|
71
|
+
*
|
|
72
|
+
* Requires `engine:readiness` permission.
|
|
69
73
|
*/
|
|
70
74
|
export const handleHealthReadiness = (
|
|
71
75
|
req: HealthReadyRequest,
|
|
72
76
|
config?: HealthCheckConfig,
|
|
73
77
|
) =>
|
|
74
78
|
Effect.gen(function* () {
|
|
79
|
+
const authService = yield* AuthContextService;
|
|
80
|
+
|
|
81
|
+
// Check permission for readiness endpoint
|
|
82
|
+
yield* authService.requirePermission(PERMISSIONS.ENGINE.READINESS);
|
|
83
|
+
|
|
75
84
|
const response = yield* performReadinessCheck(config);
|
|
76
85
|
const format = getHealthResponseFormat(req.acceptHeader);
|
|
77
86
|
|
|
@@ -106,12 +115,19 @@ export const handleHealthReadiness = (
|
|
|
106
115
|
* - Dead letter queue (if enabled)
|
|
107
116
|
*
|
|
108
117
|
* Always returns 200 OK for debugging purposes (even if components are degraded).
|
|
118
|
+
*
|
|
119
|
+
* Requires `engine:readiness` permission.
|
|
109
120
|
*/
|
|
110
121
|
export const handleHealthComponents = (
|
|
111
122
|
req: HealthComponentsRequest,
|
|
112
123
|
config?: HealthCheckConfig,
|
|
113
124
|
) =>
|
|
114
125
|
Effect.gen(function* () {
|
|
126
|
+
const authService = yield* AuthContextService;
|
|
127
|
+
|
|
128
|
+
// Check permission for components endpoint
|
|
129
|
+
yield* authService.requirePermission(PERMISSIONS.ENGINE.READINESS);
|
|
130
|
+
|
|
115
131
|
const response = yield* performComponentsCheck(config);
|
|
116
132
|
const format = getHealthResponseFormat(req.acceptHeader);
|
|
117
133
|
|
|
@@ -5,7 +5,10 @@ import { MetricsService } from "@uploadista/observability";
|
|
|
5
5
|
import { Effect } from "effect";
|
|
6
6
|
import { AuthCacheService } from "../../cache";
|
|
7
7
|
import { ValidationError } from "../../error-types";
|
|
8
|
+
import { PERMISSIONS } from "../../permissions/types";
|
|
9
|
+
import { QuotaExceededError } from "../../permissions/errors";
|
|
8
10
|
import { AuthContextService } from "../../service";
|
|
11
|
+
import { UsageHookService } from "../../usage-hooks/service";
|
|
9
12
|
import type {
|
|
10
13
|
CreateUploadRequest,
|
|
11
14
|
CreateUploadResponse,
|
|
@@ -20,11 +23,14 @@ import type {
|
|
|
20
23
|
export const handleCreateUpload = (req: CreateUploadRequest) =>
|
|
21
24
|
Effect.gen(function* () {
|
|
22
25
|
const server = yield* UploadServer;
|
|
23
|
-
// Access auth context if available
|
|
24
26
|
const authService = yield* AuthContextService;
|
|
25
27
|
const authCache = yield* AuthCacheService;
|
|
28
|
+
const usageHookService = yield* UsageHookService;
|
|
26
29
|
const clientId = yield* authService.getClientId();
|
|
27
30
|
|
|
31
|
+
// Check permission for creating uploads
|
|
32
|
+
yield* authService.requirePermission(PERMISSIONS.UPLOAD.CREATE);
|
|
33
|
+
|
|
28
34
|
if (clientId) {
|
|
29
35
|
yield* Effect.logInfo(`[Upload] Creating upload for client: ${clientId}`);
|
|
30
36
|
}
|
|
@@ -51,6 +57,28 @@ export const handleCreateUpload = (req: CreateUploadRequest) =>
|
|
|
51
57
|
);
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
// Execute onUploadStart hook for quota checking
|
|
61
|
+
if (clientId) {
|
|
62
|
+
const hookResult = yield* usageHookService.onUploadStart({
|
|
63
|
+
clientId,
|
|
64
|
+
operation: "upload",
|
|
65
|
+
metadata: {
|
|
66
|
+
fileSize: parsedInputFile.data.size,
|
|
67
|
+
mimeType: parsedInputFile.data.type,
|
|
68
|
+
fileName: parsedInputFile.data.fileName,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (hookResult.action === "abort") {
|
|
73
|
+
return yield* Effect.fail(
|
|
74
|
+
new QuotaExceededError(
|
|
75
|
+
hookResult.reason,
|
|
76
|
+
hookResult.code ?? "QUOTA_EXCEEDED",
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
54
82
|
const fileCreated = yield* server.createUpload(
|
|
55
83
|
parsedInputFile.data,
|
|
56
84
|
clientId,
|
|
@@ -80,6 +108,9 @@ export const handleGetCapabilities = ({ storageId }: GetCapabilitiesRequest) =>
|
|
|
80
108
|
const authService = yield* AuthContextService;
|
|
81
109
|
const clientId = yield* authService.getClientId();
|
|
82
110
|
|
|
111
|
+
// Check permission for reading upload capabilities
|
|
112
|
+
yield* authService.requirePermission(PERMISSIONS.UPLOAD.READ);
|
|
113
|
+
|
|
83
114
|
const capabilities = yield* server.getCapabilities(storageId, clientId);
|
|
84
115
|
|
|
85
116
|
return {
|
|
@@ -95,6 +126,11 @@ export const handleGetCapabilities = ({ storageId }: GetCapabilitiesRequest) =>
|
|
|
95
126
|
export const handleGetUpload = ({ uploadId }: GetUploadRequest) =>
|
|
96
127
|
Effect.gen(function* () {
|
|
97
128
|
const server = yield* UploadServer;
|
|
129
|
+
const authService = yield* AuthContextService;
|
|
130
|
+
|
|
131
|
+
// Check permission for reading upload status
|
|
132
|
+
yield* authService.requirePermission(PERMISSIONS.UPLOAD.READ);
|
|
133
|
+
|
|
98
134
|
const fileResult = yield* server.getUpload(uploadId);
|
|
99
135
|
|
|
100
136
|
return {
|
|
@@ -106,13 +142,16 @@ export const handleGetUpload = ({ uploadId }: GetUploadRequest) =>
|
|
|
106
142
|
export const handleUploadChunk = (req: UploadChunkRequest) =>
|
|
107
143
|
Effect.gen(function* () {
|
|
108
144
|
const server = yield* UploadServer;
|
|
109
|
-
// Try to get auth from current request or cached auth
|
|
110
145
|
const authService = yield* AuthContextService;
|
|
111
146
|
const authCache = yield* AuthCacheService;
|
|
112
147
|
const metricsService = yield* MetricsService;
|
|
148
|
+
const usageHookService = yield* UsageHookService;
|
|
113
149
|
|
|
114
150
|
const { uploadId, data } = req;
|
|
115
151
|
|
|
152
|
+
// Check permission for creating uploads (chunks are part of creation)
|
|
153
|
+
yield* authService.requirePermission(PERMISSIONS.UPLOAD.CREATE);
|
|
154
|
+
|
|
116
155
|
// Try current auth first, fallback to cached auth
|
|
117
156
|
let clientId = yield* authService.getClientId();
|
|
118
157
|
let authMetadata = yield* authService.getMetadata();
|
|
@@ -128,6 +167,7 @@ export const handleUploadChunk = (req: UploadChunkRequest) =>
|
|
|
128
167
|
);
|
|
129
168
|
}
|
|
130
169
|
|
|
170
|
+
const startTime = Date.now();
|
|
131
171
|
const fileResult = yield* server.uploadChunk(uploadId, clientId, data);
|
|
132
172
|
|
|
133
173
|
// Clear cache and record metrics if upload is complete
|
|
@@ -140,7 +180,6 @@ export const handleUploadChunk = (req: UploadChunkRequest) =>
|
|
|
140
180
|
}
|
|
141
181
|
|
|
142
182
|
// Record upload metrics if we have organization ID
|
|
143
|
-
|
|
144
183
|
if (clientId && fileResult.size) {
|
|
145
184
|
yield* Effect.logInfo(
|
|
146
185
|
`[Upload] Recording metrics for org: ${clientId}, size: ${fileResult.size}`,
|
|
@@ -148,6 +187,20 @@ export const handleUploadChunk = (req: UploadChunkRequest) =>
|
|
|
148
187
|
yield* Effect.forkDaemon(
|
|
149
188
|
metricsService.recordUpload(clientId, fileResult.size, authMetadata),
|
|
150
189
|
);
|
|
190
|
+
|
|
191
|
+
// Execute onUploadComplete hook for usage tracking
|
|
192
|
+
const duration = Date.now() - startTime;
|
|
193
|
+
yield* Effect.forkDaemon(
|
|
194
|
+
usageHookService.onUploadComplete({
|
|
195
|
+
clientId,
|
|
196
|
+
operation: "upload",
|
|
197
|
+
metadata: {
|
|
198
|
+
uploadId,
|
|
199
|
+
fileSize: fileResult.size,
|
|
200
|
+
duration,
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
151
204
|
} else {
|
|
152
205
|
yield* Effect.logWarning(
|
|
153
206
|
`[Upload] Cannot record metrics - missing organizationId or size`,
|
package/src/core/server.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { handleFlowError } from "../http-utils";
|
|
|
24
24
|
import { createFlowServerLayer, createUploadServerLayer } from "../layer-utils";
|
|
25
25
|
import { AuthContextServiceLive } from "../service";
|
|
26
26
|
import type { AuthContext } from "../types";
|
|
27
|
+
import { UsageHookServiceLive } from "../usage-hooks/service";
|
|
27
28
|
import { handleUploadistaRequest } from "./http-handlers/http-handlers";
|
|
28
29
|
import type { ExtractFlowPluginRequirements } from "./plugin-types";
|
|
29
30
|
import type { NotFoundResponse } from "./routes";
|
|
@@ -201,6 +202,7 @@ export const createUploadistaServer = async <
|
|
|
201
202
|
circuitBreaker = true,
|
|
202
203
|
deadLetterQueue = false,
|
|
203
204
|
healthCheck,
|
|
205
|
+
usageHooks,
|
|
204
206
|
}: UploadistaServerConfig<
|
|
205
207
|
TContext,
|
|
206
208
|
TResponse,
|
|
@@ -285,17 +287,22 @@ export const createUploadistaServer = async <
|
|
|
285
287
|
)
|
|
286
288
|
: null;
|
|
287
289
|
|
|
290
|
+
// Create usage hook layer (defaults to no-op if not configured)
|
|
291
|
+
const usageHookLayer = UsageHookServiceLive(usageHooks);
|
|
292
|
+
|
|
288
293
|
/**
|
|
289
294
|
* Merge all server layers including plugins.
|
|
290
295
|
*
|
|
291
296
|
* This combines the core server infrastructure (upload server, flow server,
|
|
292
|
-
* metrics, auth cache, circuit breaker, dead letter queue
|
|
297
|
+
* metrics, auth cache, circuit breaker, dead letter queue, usage hooks)
|
|
298
|
+
* with user-provided plugin layers.
|
|
293
299
|
*/
|
|
294
300
|
const serverLayerRaw = Layer.mergeAll(
|
|
295
301
|
uploadServerLayer,
|
|
296
302
|
flowServerLayer,
|
|
297
303
|
effectiveMetricsLayer,
|
|
298
304
|
authCacheLayer,
|
|
305
|
+
usageHookLayer,
|
|
299
306
|
...plugins,
|
|
300
307
|
...(circuitBreakerStoreLayer ? [circuitBreakerStoreLayer] : []),
|
|
301
308
|
...(dlqLayer ? [dlqLayer] : []),
|
|
@@ -497,12 +504,13 @@ export const createUploadistaServer = async <
|
|
|
497
504
|
}
|
|
498
505
|
}
|
|
499
506
|
|
|
500
|
-
// Combine auth context, auth cache, metrics layers, plugins, circuit breaker, DLQ, and waitUntil
|
|
507
|
+
// Combine auth context, auth cache, metrics layers, usage hooks, plugins, circuit breaker, DLQ, and waitUntil
|
|
501
508
|
// This ensures that flow nodes have access to all required services
|
|
502
509
|
const baseRequestContextLayer = Layer.mergeAll(
|
|
503
510
|
authContextLayer,
|
|
504
511
|
authCacheLayer,
|
|
505
512
|
effectiveMetricsLayer,
|
|
513
|
+
usageHookLayer,
|
|
506
514
|
...plugins,
|
|
507
515
|
...waitUntilLayers,
|
|
508
516
|
);
|
package/src/core/types.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { Effect, Layer } from "effect";
|
|
|
15
15
|
import type { z } from "zod";
|
|
16
16
|
import type { ServerAdapter } from "../adapter";
|
|
17
17
|
import type { AuthCacheConfig } from "../cache";
|
|
18
|
+
import type { UsageHookConfig } from "../usage-hooks/types";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Function type for retrieving flows based on flow ID and client ID.
|
|
@@ -374,6 +375,40 @@ export interface UploadistaServerConfig<
|
|
|
374
375
|
* ```
|
|
375
376
|
*/
|
|
376
377
|
healthCheck?: HealthCheckConfig;
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Optional: Usage hooks for tracking and billing integration.
|
|
381
|
+
*
|
|
382
|
+
* Usage hooks allow you to intercept upload and flow operations for:
|
|
383
|
+
* - Quota checking (e.g., verify user has subscription)
|
|
384
|
+
* - Usage tracking (e.g., count uploads, track bandwidth)
|
|
385
|
+
* - Billing integration (e.g., report usage to Stripe/Polar)
|
|
386
|
+
*
|
|
387
|
+
* Hooks follow a "fail-open" design - if a hook times out or errors,
|
|
388
|
+
* the operation proceeds (unless the hook explicitly aborts).
|
|
389
|
+
*
|
|
390
|
+
* @example
|
|
391
|
+
* ```typescript
|
|
392
|
+
* usageHooks: {
|
|
393
|
+
* hooks: {
|
|
394
|
+
* onUploadStart: (ctx) => Effect.gen(function* () {
|
|
395
|
+
* // Check quota before upload starts
|
|
396
|
+
* const quota = yield* checkUserQuota(ctx.clientId);
|
|
397
|
+
* if (quota.exceeded) {
|
|
398
|
+
* return { action: "abort", reason: "Storage quota exceeded" };
|
|
399
|
+
* }
|
|
400
|
+
* return { action: "continue" };
|
|
401
|
+
* }),
|
|
402
|
+
* onUploadComplete: (ctx) => Effect.gen(function* () {
|
|
403
|
+
* // Track usage after upload completes
|
|
404
|
+
* yield* reportUsage(ctx.clientId, ctx.metadata.fileSize);
|
|
405
|
+
* }),
|
|
406
|
+
* },
|
|
407
|
+
* timeout: 5000, // 5 second timeout for hooks
|
|
408
|
+
* }
|
|
409
|
+
* ```
|
|
410
|
+
*/
|
|
411
|
+
usageHooks?: UsageHookConfig;
|
|
377
412
|
}
|
|
378
413
|
|
|
379
414
|
/**
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,8 @@ export * from "./core";
|
|
|
5
5
|
export * from "./error-types";
|
|
6
6
|
export * from "./http-utils";
|
|
7
7
|
export * from "./layer-utils";
|
|
8
|
+
export * from "./permissions";
|
|
8
9
|
export * from "./plugins-typing";
|
|
9
10
|
export * from "./service";
|
|
10
11
|
export * from "./types";
|
|
12
|
+
export * from "./usage-hooks";
|