@uploadista/server 0.0.3
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/.turbo/turbo-build.log +5 -0
- package/.turbo/turbo-check.log +34 -0
- package/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/auth/cache.d.ts +87 -0
- package/dist/auth/cache.d.ts.map +1 -0
- package/dist/auth/cache.js +121 -0
- package/dist/auth/cache.test.d.ts +2 -0
- package/dist/auth/cache.test.d.ts.map +1 -0
- package/dist/auth/cache.test.js +209 -0
- package/dist/auth/get-auth-credentials.d.ts +73 -0
- package/dist/auth/get-auth-credentials.d.ts.map +1 -0
- package/dist/auth/get-auth-credentials.js +55 -0
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/jwt/index.d.ts +38 -0
- package/dist/auth/jwt/index.d.ts.map +1 -0
- package/dist/auth/jwt/index.js +36 -0
- package/dist/auth/jwt/types.d.ts +77 -0
- package/dist/auth/jwt/types.d.ts.map +1 -0
- package/dist/auth/jwt/types.js +1 -0
- package/dist/auth/jwt/validate.d.ts +58 -0
- package/dist/auth/jwt/validate.d.ts.map +1 -0
- package/dist/auth/jwt/validate.js +226 -0
- package/dist/auth/jwt/validate.test.d.ts +2 -0
- package/dist/auth/jwt/validate.test.d.ts.map +1 -0
- package/dist/auth/jwt/validate.test.js +492 -0
- package/dist/auth/service.d.ts +63 -0
- package/dist/auth/service.d.ts.map +1 -0
- package/dist/auth/service.js +43 -0
- package/dist/auth/service.test.d.ts +2 -0
- package/dist/auth/service.test.d.ts.map +1 -0
- package/dist/auth/service.test.js +195 -0
- package/dist/auth/types.d.ts +38 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +1 -0
- package/dist/cache.d.ts +87 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +121 -0
- package/dist/cache.test.d.ts +2 -0
- package/dist/cache.test.d.ts.map +1 -0
- package/dist/cache.test.js +209 -0
- package/dist/cloudflare-config.d.ts +72 -0
- package/dist/cloudflare-config.d.ts.map +1 -0
- package/dist/cloudflare-config.js +67 -0
- package/dist/error-types.d.ts +138 -0
- package/dist/error-types.d.ts.map +1 -0
- package/dist/error-types.js +155 -0
- package/dist/hono-adapter.d.ts +48 -0
- package/dist/hono-adapter.d.ts.map +1 -0
- package/dist/hono-adapter.js +58 -0
- package/dist/http-utils.d.ts +148 -0
- package/dist/http-utils.d.ts.map +1 -0
- package/dist/http-utils.js +233 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/layer-utils.d.ts +121 -0
- package/dist/layer-utils.d.ts.map +1 -0
- package/dist/layer-utils.js +80 -0
- package/dist/metrics/service.d.ts +26 -0
- package/dist/metrics/service.d.ts.map +1 -0
- package/dist/metrics/service.js +20 -0
- package/dist/plugins-typing.d.ts +11 -0
- package/dist/plugins-typing.d.ts.map +1 -0
- package/dist/plugins-typing.js +1 -0
- package/dist/service.d.ts +63 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +43 -0
- package/dist/service.test.d.ts +2 -0
- package/dist/service.test.d.ts.map +1 -0
- package/dist/service.test.js +195 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +47 -0
- package/src/auth/get-auth-credentials.ts +97 -0
- package/src/auth/index.ts +1 -0
- package/src/cache.test.ts +306 -0
- package/src/cache.ts +204 -0
- package/src/error-types.ts +172 -0
- package/src/http-utils.ts +264 -0
- package/src/index.ts +8 -0
- package/src/layer-utils.ts +184 -0
- package/src/plugins-typing.ts +57 -0
- package/src/service.test.ts +275 -0
- package/src/service.ts +78 -0
- package/src/types.ts +40 -0
- package/tsconfig.json +13 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { AuthContextService, AuthContextServiceLive, NoAuthContextServiceLive, } from "./service";
|
|
4
|
+
describe("AuthContextService", () => {
|
|
5
|
+
describe("AuthContextServiceLive", () => {
|
|
6
|
+
it("should return userId when auth context is provided", async () => {
|
|
7
|
+
const authContext = {
|
|
8
|
+
clientId: "user-123",
|
|
9
|
+
metadata: { role: "admin" },
|
|
10
|
+
permissions: ["upload:create", "flow:execute"],
|
|
11
|
+
};
|
|
12
|
+
const layer = AuthContextServiceLive(authContext);
|
|
13
|
+
const program = Effect.gen(function* () {
|
|
14
|
+
const service = yield* AuthContextService;
|
|
15
|
+
return yield* service.getClientId();
|
|
16
|
+
});
|
|
17
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
18
|
+
expect(result).toBe("user-123");
|
|
19
|
+
});
|
|
20
|
+
it("should return null when auth context is null", async () => {
|
|
21
|
+
const layer = AuthContextServiceLive(null);
|
|
22
|
+
const program = Effect.gen(function* () {
|
|
23
|
+
const service = yield* AuthContextService;
|
|
24
|
+
return yield* service.getClientId();
|
|
25
|
+
});
|
|
26
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
27
|
+
expect(result).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
it("should return metadata when auth context is provided", async () => {
|
|
30
|
+
const authContext = {
|
|
31
|
+
clientId: "user-123",
|
|
32
|
+
metadata: { role: "admin", tier: "premium" },
|
|
33
|
+
permissions: [],
|
|
34
|
+
};
|
|
35
|
+
const layer = AuthContextServiceLive(authContext);
|
|
36
|
+
const program = Effect.gen(function* () {
|
|
37
|
+
const service = yield* AuthContextService;
|
|
38
|
+
return yield* service.getMetadata();
|
|
39
|
+
});
|
|
40
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
41
|
+
expect(result).toEqual({ role: "admin", tier: "premium" });
|
|
42
|
+
});
|
|
43
|
+
it("should return empty object when auth context has no metadata", async () => {
|
|
44
|
+
const authContext = {
|
|
45
|
+
clientId: "user-123",
|
|
46
|
+
};
|
|
47
|
+
const layer = AuthContextServiceLive(authContext);
|
|
48
|
+
const program = Effect.gen(function* () {
|
|
49
|
+
const service = yield* AuthContextService;
|
|
50
|
+
return yield* service.getMetadata();
|
|
51
|
+
});
|
|
52
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
53
|
+
expect(result).toEqual({});
|
|
54
|
+
});
|
|
55
|
+
it("should return empty object when auth context is null", async () => {
|
|
56
|
+
const layer = AuthContextServiceLive(null);
|
|
57
|
+
const program = Effect.gen(function* () {
|
|
58
|
+
const service = yield* AuthContextService;
|
|
59
|
+
return yield* service.getMetadata();
|
|
60
|
+
});
|
|
61
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
62
|
+
expect(result).toEqual({});
|
|
63
|
+
});
|
|
64
|
+
it("should return true for existing permission", async () => {
|
|
65
|
+
const authContext = {
|
|
66
|
+
clientId: "user-123",
|
|
67
|
+
permissions: ["upload:create", "flow:execute", "admin:read"],
|
|
68
|
+
};
|
|
69
|
+
const layer = AuthContextServiceLive(authContext);
|
|
70
|
+
const program = Effect.gen(function* () {
|
|
71
|
+
const service = yield* AuthContextService;
|
|
72
|
+
return yield* service.hasPermission("flow:execute");
|
|
73
|
+
});
|
|
74
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
75
|
+
expect(result).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
it("should return false for non-existing permission", async () => {
|
|
78
|
+
const authContext = {
|
|
79
|
+
clientId: "user-123",
|
|
80
|
+
permissions: ["upload:create", "flow:execute"],
|
|
81
|
+
};
|
|
82
|
+
const layer = AuthContextServiceLive(authContext);
|
|
83
|
+
const program = Effect.gen(function* () {
|
|
84
|
+
const service = yield* AuthContextService;
|
|
85
|
+
return yield* service.hasPermission("admin:write");
|
|
86
|
+
});
|
|
87
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
88
|
+
expect(result).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
it("should return false for permission check when no permissions array", async () => {
|
|
91
|
+
const authContext = {
|
|
92
|
+
clientId: "user-123",
|
|
93
|
+
};
|
|
94
|
+
const layer = AuthContextServiceLive(authContext);
|
|
95
|
+
const program = Effect.gen(function* () {
|
|
96
|
+
const service = yield* AuthContextService;
|
|
97
|
+
return yield* service.hasPermission("upload:create");
|
|
98
|
+
});
|
|
99
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
100
|
+
expect(result).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
it("should return false for permission check when auth context is null", async () => {
|
|
103
|
+
const layer = AuthContextServiceLive(null);
|
|
104
|
+
const program = Effect.gen(function* () {
|
|
105
|
+
const service = yield* AuthContextService;
|
|
106
|
+
return yield* service.hasPermission("upload:create");
|
|
107
|
+
});
|
|
108
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
109
|
+
expect(result).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
it("should return full auth context when provided", async () => {
|
|
112
|
+
const authContext = {
|
|
113
|
+
clientId: "user-123",
|
|
114
|
+
metadata: { role: "admin" },
|
|
115
|
+
permissions: ["upload:create"],
|
|
116
|
+
};
|
|
117
|
+
const layer = AuthContextServiceLive(authContext);
|
|
118
|
+
const program = Effect.gen(function* () {
|
|
119
|
+
const service = yield* AuthContextService;
|
|
120
|
+
return yield* service.getAuthContext();
|
|
121
|
+
});
|
|
122
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
123
|
+
expect(result).toEqual(authContext);
|
|
124
|
+
});
|
|
125
|
+
it("should return null auth context when not provided", async () => {
|
|
126
|
+
const layer = AuthContextServiceLive(null);
|
|
127
|
+
const program = Effect.gen(function* () {
|
|
128
|
+
const service = yield* AuthContextService;
|
|
129
|
+
return yield* service.getAuthContext();
|
|
130
|
+
});
|
|
131
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
132
|
+
expect(result).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe("NoAuthContextServiceLive", () => {
|
|
136
|
+
it("should return null for getUserId", async () => {
|
|
137
|
+
const program = Effect.gen(function* () {
|
|
138
|
+
const service = yield* AuthContextService;
|
|
139
|
+
return yield* service.getClientId();
|
|
140
|
+
});
|
|
141
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(NoAuthContextServiceLive)));
|
|
142
|
+
expect(result).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
it("should return empty object for getMetadata", async () => {
|
|
145
|
+
const program = Effect.gen(function* () {
|
|
146
|
+
const service = yield* AuthContextService;
|
|
147
|
+
return yield* service.getMetadata();
|
|
148
|
+
});
|
|
149
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(NoAuthContextServiceLive)));
|
|
150
|
+
expect(result).toEqual({});
|
|
151
|
+
});
|
|
152
|
+
it("should return false for any permission check", async () => {
|
|
153
|
+
const program = Effect.gen(function* () {
|
|
154
|
+
const service = yield* AuthContextService;
|
|
155
|
+
const result1 = yield* service.hasPermission("upload:create");
|
|
156
|
+
const result2 = yield* service.hasPermission("admin:write");
|
|
157
|
+
return { result1, result2 };
|
|
158
|
+
});
|
|
159
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(NoAuthContextServiceLive)));
|
|
160
|
+
expect(result.result1).toBe(false);
|
|
161
|
+
expect(result.result2).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
it("should return null for getAuthContext", async () => {
|
|
164
|
+
const program = Effect.gen(function* () {
|
|
165
|
+
const service = yield* AuthContextService;
|
|
166
|
+
return yield* service.getAuthContext();
|
|
167
|
+
});
|
|
168
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(NoAuthContextServiceLive)));
|
|
169
|
+
expect(result).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe("Effect Layer composition", () => {
|
|
173
|
+
it("should work in composed effect programs", async () => {
|
|
174
|
+
const authContext = {
|
|
175
|
+
clientId: "user-456",
|
|
176
|
+
metadata: { department: "engineering" },
|
|
177
|
+
permissions: ["flow:execute"],
|
|
178
|
+
};
|
|
179
|
+
const layer = AuthContextServiceLive(authContext);
|
|
180
|
+
const program = Effect.gen(function* () {
|
|
181
|
+
const service = yield* AuthContextService;
|
|
182
|
+
const clientId = yield* service.getClientId();
|
|
183
|
+
const metadata = yield* service.getMetadata();
|
|
184
|
+
const hasPermission = yield* service.hasPermission("flow:execute");
|
|
185
|
+
return { clientId, metadata, hasPermission };
|
|
186
|
+
});
|
|
187
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
188
|
+
expect(result).toEqual({
|
|
189
|
+
clientId: "user-456",
|
|
190
|
+
metadata: { department: "engineering" },
|
|
191
|
+
hasPermission: true,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication context containing user identity and authorization metadata.
|
|
3
|
+
* This context is extracted from authentication middleware and made available
|
|
4
|
+
* throughout the upload and flow processing pipeline via Effect Layer.
|
|
5
|
+
*/
|
|
6
|
+
export type AuthContext = {
|
|
7
|
+
/**
|
|
8
|
+
* Unique identifier for the authenticated user.
|
|
9
|
+
* This is typically extracted from JWT claims (sub), session data, or API key metadata.
|
|
10
|
+
*/
|
|
11
|
+
clientId: string;
|
|
12
|
+
/**
|
|
13
|
+
* Optional metadata for authorization and tracking purposes.
|
|
14
|
+
* Can include rate limits, quotas, permissions, or custom application data.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* {
|
|
19
|
+
* permissions: ['upload:create', 'flow:execute'],
|
|
20
|
+
* rateLimit: { requests: 1000, period: 3600 },
|
|
21
|
+
* quota: { storage: 10737418240, used: 5368709120 }
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
metadata?: Record<string, unknown>;
|
|
26
|
+
/**
|
|
27
|
+
* Optional list of permissions granted to the user.
|
|
28
|
+
* These can be used for fine-grained access control in the future.
|
|
29
|
+
*/
|
|
30
|
+
permissions?: string[];
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Result type for authentication middleware.
|
|
34
|
+
* - AuthContext: Successful authentication with user identity
|
|
35
|
+
* - null: Authentication failed or not authenticated
|
|
36
|
+
*/
|
|
37
|
+
export type AuthResult = AuthContext | null;
|
|
38
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/auth/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAEnC;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,IAAI,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import type { AuthContext } from "./types";
|
|
3
|
+
/**
|
|
4
|
+
* Configuration options for the auth cache.
|
|
5
|
+
*/
|
|
6
|
+
export type AuthCacheConfig = {
|
|
7
|
+
/**
|
|
8
|
+
* Maximum number of entries in the cache.
|
|
9
|
+
* When exceeded, oldest entries are removed (LRU eviction).
|
|
10
|
+
* @default 10000
|
|
11
|
+
*/
|
|
12
|
+
maxSize?: number;
|
|
13
|
+
/**
|
|
14
|
+
* Time-to-live for cache entries in milliseconds.
|
|
15
|
+
* Entries older than this will be automatically evicted.
|
|
16
|
+
* @default 3600000 (1 hour)
|
|
17
|
+
*/
|
|
18
|
+
ttl?: number;
|
|
19
|
+
};
|
|
20
|
+
declare const AuthCacheService_base: Context.TagClass<AuthCacheService, "AuthCacheService", {
|
|
21
|
+
/**
|
|
22
|
+
* Store an auth context for a job ID.
|
|
23
|
+
*/
|
|
24
|
+
readonly set: (jobId: string, authContext: AuthContext) => Effect.Effect<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Retrieve a cached auth context by job ID.
|
|
27
|
+
* Returns null if not found or expired.
|
|
28
|
+
*/
|
|
29
|
+
readonly get: (jobId: string) => Effect.Effect<AuthContext | null>;
|
|
30
|
+
/**
|
|
31
|
+
* Delete a cached auth context by job ID.
|
|
32
|
+
*/
|
|
33
|
+
readonly delete: (jobId: string) => Effect.Effect<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Clear all cached auth contexts.
|
|
36
|
+
*/
|
|
37
|
+
readonly clear: () => Effect.Effect<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Get the current number of cached entries.
|
|
40
|
+
*/
|
|
41
|
+
readonly size: () => Effect.Effect<number>;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Auth Cache Service
|
|
45
|
+
*
|
|
46
|
+
* Provides caching of authentication contexts for upload and flow jobs.
|
|
47
|
+
* This allows subsequent operations (chunk uploads, flow continuations)
|
|
48
|
+
* to reuse the auth context from the initial request without re-authenticating.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* import { Effect } from "effect";
|
|
53
|
+
* import { AuthCacheService } from "@uploadista/server";
|
|
54
|
+
*
|
|
55
|
+
* const handler = Effect.gen(function* () {
|
|
56
|
+
* const authCache = yield* AuthCacheService;
|
|
57
|
+
* const authContext = { userId: "user-123" };
|
|
58
|
+
*
|
|
59
|
+
* // Cache auth for upload
|
|
60
|
+
* yield* authCache.set("upload-abc", authContext);
|
|
61
|
+
*
|
|
62
|
+
* // Retrieve cached auth later
|
|
63
|
+
* const cached = yield* authCache.get("upload-abc");
|
|
64
|
+
* console.log(cached?.userId); // "user-123"
|
|
65
|
+
*
|
|
66
|
+
* // Clear when done
|
|
67
|
+
* yield* authCache.delete("upload-abc");
|
|
68
|
+
* });
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export declare class AuthCacheService extends AuthCacheService_base {
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Creates an AuthCacheService Layer with in-memory storage.
|
|
75
|
+
*
|
|
76
|
+
* @param config - Optional configuration for cache behavior
|
|
77
|
+
* @returns Effect Layer providing AuthCacheService
|
|
78
|
+
*/
|
|
79
|
+
export declare const AuthCacheServiceLive: (config?: AuthCacheConfig) => Layer.Layer<AuthCacheService>;
|
|
80
|
+
/**
|
|
81
|
+
* No-op implementation of AuthCacheService.
|
|
82
|
+
* Does not cache anything - all operations are no-ops.
|
|
83
|
+
* Used when caching is disabled or not needed.
|
|
84
|
+
*/
|
|
85
|
+
export declare const NoAuthCacheServiceLive: Layer.Layer<AuthCacheService>;
|
|
86
|
+
export {};
|
|
87
|
+
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAChD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;;IAyCE;;OAEG;kBACW,CACZ,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,WAAW,KACrB,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;IAExB;;;OAGG;kBACW,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,WAAW,GAAG,IAAI,CAAC;IAElE;;OAEG;qBACc,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;IAEvD;;OAEG;oBACa,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;IAEzC;;OAEG;mBACY,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;;AA1D9C;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,qBAAa,gBAAiB,SAAQ,qBAgCnC;CAAG;AAEN;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,GAC/B,SAAQ,eAAoB,KAC3B,KAAK,CAAC,KAAK,CAAC,gBAAgB,CAyF9B,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,KAAK,CAAC,KAAK,CAAC,gBAAgB,CAO7D,CAAC"}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* Auth Cache Service
|
|
4
|
+
*
|
|
5
|
+
* Provides caching of authentication contexts for upload and flow jobs.
|
|
6
|
+
* This allows subsequent operations (chunk uploads, flow continuations)
|
|
7
|
+
* to reuse the auth context from the initial request without re-authenticating.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { Effect } from "effect";
|
|
12
|
+
* import { AuthCacheService } from "@uploadista/server";
|
|
13
|
+
*
|
|
14
|
+
* const handler = Effect.gen(function* () {
|
|
15
|
+
* const authCache = yield* AuthCacheService;
|
|
16
|
+
* const authContext = { userId: "user-123" };
|
|
17
|
+
*
|
|
18
|
+
* // Cache auth for upload
|
|
19
|
+
* yield* authCache.set("upload-abc", authContext);
|
|
20
|
+
*
|
|
21
|
+
* // Retrieve cached auth later
|
|
22
|
+
* const cached = yield* authCache.get("upload-abc");
|
|
23
|
+
* console.log(cached?.userId); // "user-123"
|
|
24
|
+
*
|
|
25
|
+
* // Clear when done
|
|
26
|
+
* yield* authCache.delete("upload-abc");
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export class AuthCacheService extends Context.Tag("AuthCacheService")() {
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Creates an AuthCacheService Layer with in-memory storage.
|
|
34
|
+
*
|
|
35
|
+
* @param config - Optional configuration for cache behavior
|
|
36
|
+
* @returns Effect Layer providing AuthCacheService
|
|
37
|
+
*/
|
|
38
|
+
export const AuthCacheServiceLive = (config = {}) => {
|
|
39
|
+
const maxSize = config.maxSize ?? 10000;
|
|
40
|
+
const ttl = config.ttl ?? 3600000; // 1 hour default
|
|
41
|
+
// In-memory cache storage
|
|
42
|
+
const cache = new Map();
|
|
43
|
+
/**
|
|
44
|
+
* Evict expired entries based on TTL.
|
|
45
|
+
*/
|
|
46
|
+
const evictExpired = () => {
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
for (const [jobId, entry] of cache.entries()) {
|
|
49
|
+
if (now - entry.timestamp > ttl) {
|
|
50
|
+
cache.delete(jobId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Enforce max size limit using LRU eviction.
|
|
56
|
+
* Removes oldest entry when cache exceeds max size.
|
|
57
|
+
*/
|
|
58
|
+
const enforceSizeLimit = () => {
|
|
59
|
+
if (cache.size <= maxSize)
|
|
60
|
+
return;
|
|
61
|
+
// Find and remove oldest entry
|
|
62
|
+
let oldestKey = null;
|
|
63
|
+
let oldestTime = Number.POSITIVE_INFINITY;
|
|
64
|
+
for (const [jobId, entry] of cache.entries()) {
|
|
65
|
+
if (entry.timestamp < oldestTime) {
|
|
66
|
+
oldestTime = entry.timestamp;
|
|
67
|
+
oldestKey = jobId;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (oldestKey) {
|
|
71
|
+
cache.delete(oldestKey);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return Layer.succeed(AuthCacheService, {
|
|
75
|
+
set: (jobId, authContext) => Effect.sync(() => {
|
|
76
|
+
// Evict expired entries periodically
|
|
77
|
+
if (cache.size % 100 === 0) {
|
|
78
|
+
evictExpired();
|
|
79
|
+
}
|
|
80
|
+
cache.set(jobId, {
|
|
81
|
+
authContext,
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
});
|
|
84
|
+
// Enforce size limit after adding
|
|
85
|
+
enforceSizeLimit();
|
|
86
|
+
}),
|
|
87
|
+
get: (jobId) => Effect.sync(() => {
|
|
88
|
+
const entry = cache.get(jobId);
|
|
89
|
+
if (!entry)
|
|
90
|
+
return null;
|
|
91
|
+
// Check if expired
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (now - entry.timestamp > ttl) {
|
|
94
|
+
cache.delete(jobId);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return entry.authContext;
|
|
98
|
+
}),
|
|
99
|
+
delete: (jobId) => Effect.sync(() => {
|
|
100
|
+
cache.delete(jobId);
|
|
101
|
+
}),
|
|
102
|
+
clear: () => Effect.sync(() => {
|
|
103
|
+
cache.clear();
|
|
104
|
+
}),
|
|
105
|
+
size: () => Effect.sync(() => {
|
|
106
|
+
return cache.size;
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* No-op implementation of AuthCacheService.
|
|
112
|
+
* Does not cache anything - all operations are no-ops.
|
|
113
|
+
* Used when caching is disabled or not needed.
|
|
114
|
+
*/
|
|
115
|
+
export const NoAuthCacheServiceLive = Layer.succeed(AuthCacheService, {
|
|
116
|
+
set: () => Effect.void,
|
|
117
|
+
get: () => Effect.succeed(null),
|
|
118
|
+
delete: () => Effect.void,
|
|
119
|
+
clear: () => Effect.void,
|
|
120
|
+
size: () => Effect.succeed(0),
|
|
121
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.test.d.ts","sourceRoot":"","sources":["../src/cache.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { AuthCacheService, AuthCacheServiceLive, NoAuthCacheServiceLive, } from "./cache";
|
|
4
|
+
describe("AuthCacheService", () => {
|
|
5
|
+
describe("AuthCacheServiceLive", () => {
|
|
6
|
+
it("should set and get auth context", async () => {
|
|
7
|
+
const authContext = {
|
|
8
|
+
clientId: "user-123",
|
|
9
|
+
metadata: { role: "admin" },
|
|
10
|
+
};
|
|
11
|
+
const program = Effect.gen(function* () {
|
|
12
|
+
const cache = yield* AuthCacheService;
|
|
13
|
+
// Set auth context
|
|
14
|
+
yield* cache.set("job-1", authContext);
|
|
15
|
+
// Get auth context
|
|
16
|
+
const retrieved = yield* cache.get("job-1");
|
|
17
|
+
expect(retrieved).toEqual(authContext);
|
|
18
|
+
});
|
|
19
|
+
await Effect.runPromise(program.pipe(Effect.provide(AuthCacheServiceLive())));
|
|
20
|
+
});
|
|
21
|
+
it("should return null for non-existent job ID", async () => {
|
|
22
|
+
const program = Effect.gen(function* () {
|
|
23
|
+
const cache = yield* AuthCacheService;
|
|
24
|
+
const retrieved = yield* cache.get("non-existent");
|
|
25
|
+
expect(retrieved).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
await Effect.runPromise(program.pipe(Effect.provide(AuthCacheServiceLive())));
|
|
28
|
+
});
|
|
29
|
+
it("should delete cached auth context", async () => {
|
|
30
|
+
const authContext = {
|
|
31
|
+
clientId: "user-123",
|
|
32
|
+
};
|
|
33
|
+
const program = Effect.gen(function* () {
|
|
34
|
+
const cache = yield* AuthCacheService;
|
|
35
|
+
// Set and verify
|
|
36
|
+
yield* cache.set("job-1", authContext);
|
|
37
|
+
const before = yield* cache.get("job-1");
|
|
38
|
+
expect(before).toEqual(authContext);
|
|
39
|
+
// Delete
|
|
40
|
+
yield* cache.delete("job-1");
|
|
41
|
+
// Verify deleted
|
|
42
|
+
const after = yield* cache.get("job-1");
|
|
43
|
+
expect(after).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
await Effect.runPromise(program.pipe(Effect.provide(AuthCacheServiceLive())));
|
|
46
|
+
});
|
|
47
|
+
it("should clear all cached auth contexts", async () => {
|
|
48
|
+
const authContext1 = { clientId: "user-1" };
|
|
49
|
+
const authContext2 = { clientId: "user-2" };
|
|
50
|
+
const program = Effect.gen(function* () {
|
|
51
|
+
const cache = yield* AuthCacheService;
|
|
52
|
+
// Set multiple entries
|
|
53
|
+
yield* cache.set("job-1", authContext1);
|
|
54
|
+
yield* cache.set("job-2", authContext2);
|
|
55
|
+
const sizeBefore = yield* cache.size();
|
|
56
|
+
expect(sizeBefore).toBe(2);
|
|
57
|
+
// Clear all
|
|
58
|
+
yield* cache.clear();
|
|
59
|
+
const sizeAfter = yield* cache.size();
|
|
60
|
+
expect(sizeAfter).toBe(0);
|
|
61
|
+
// Verify both deleted
|
|
62
|
+
const job1 = yield* cache.get("job-1");
|
|
63
|
+
const job2 = yield* cache.get("job-2");
|
|
64
|
+
expect(job1).toBeNull();
|
|
65
|
+
expect(job2).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
await Effect.runPromise(program.pipe(Effect.provide(AuthCacheServiceLive())));
|
|
68
|
+
});
|
|
69
|
+
it("should track cache size", async () => {
|
|
70
|
+
const program = Effect.gen(function* () {
|
|
71
|
+
const cache = yield* AuthCacheService;
|
|
72
|
+
let size = yield* cache.size();
|
|
73
|
+
expect(size).toBe(0);
|
|
74
|
+
yield* cache.set("job-1", { clientId: "user-1" });
|
|
75
|
+
size = yield* cache.size();
|
|
76
|
+
expect(size).toBe(1);
|
|
77
|
+
yield* cache.set("job-2", { clientId: "user-2" });
|
|
78
|
+
size = yield* cache.size();
|
|
79
|
+
expect(size).toBe(2);
|
|
80
|
+
yield* cache.delete("job-1");
|
|
81
|
+
size = yield* cache.size();
|
|
82
|
+
expect(size).toBe(1);
|
|
83
|
+
yield* cache.clear();
|
|
84
|
+
size = yield* cache.size();
|
|
85
|
+
expect(size).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
await Effect.runPromise(program.pipe(Effect.provide(AuthCacheServiceLive())));
|
|
88
|
+
});
|
|
89
|
+
it("should evict expired entries", async () => {
|
|
90
|
+
// Set very short TTL for testing
|
|
91
|
+
const shortTtl = 50; // 50ms
|
|
92
|
+
const authContext = {
|
|
93
|
+
clientId: "user-123",
|
|
94
|
+
};
|
|
95
|
+
const program = Effect.gen(function* () {
|
|
96
|
+
const cache = yield* AuthCacheService;
|
|
97
|
+
// Set auth context
|
|
98
|
+
yield* cache.set("job-1", authContext);
|
|
99
|
+
// Verify it's there immediately
|
|
100
|
+
const immediate = yield* cache.get("job-1");
|
|
101
|
+
expect(immediate).toEqual(authContext);
|
|
102
|
+
// Wait for TTL to expire
|
|
103
|
+
yield* Effect.sleep(100); // Wait 100ms (longer than 50ms TTL)
|
|
104
|
+
// Should be evicted now
|
|
105
|
+
const afterExpiry = yield* cache.get("job-1");
|
|
106
|
+
expect(afterExpiry).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
await Effect.runPromise(program.pipe(Effect.provide(AuthCacheServiceLive({ ttl: shortTtl }))));
|
|
109
|
+
});
|
|
110
|
+
it("should enforce max size limit with LRU eviction", async () => {
|
|
111
|
+
const maxSize = 3;
|
|
112
|
+
const program = Effect.gen(function* () {
|
|
113
|
+
const cache = yield* AuthCacheService;
|
|
114
|
+
// Add entries up to max size
|
|
115
|
+
yield* cache.set("job-1", { clientId: "user-1" });
|
|
116
|
+
yield* Effect.sleep(10); // Small delay to ensure ordering
|
|
117
|
+
yield* cache.set("job-2", { clientId: "user-2" });
|
|
118
|
+
yield* Effect.sleep(10);
|
|
119
|
+
yield* cache.set("job-3", { clientId: "user-3" });
|
|
120
|
+
let size = yield* cache.size();
|
|
121
|
+
expect(size).toBe(3);
|
|
122
|
+
// Add one more - should evict oldest (job-1)
|
|
123
|
+
yield* Effect.sleep(10);
|
|
124
|
+
yield* cache.set("job-4", { clientId: "user-4" });
|
|
125
|
+
size = yield* cache.size();
|
|
126
|
+
expect(size).toBe(3); // Still at max size
|
|
127
|
+
// job-1 should be evicted (oldest)
|
|
128
|
+
const job1 = yield* cache.get("job-1");
|
|
129
|
+
expect(job1).toBeNull();
|
|
130
|
+
// Others should still exist
|
|
131
|
+
const job2 = yield* cache.get("job-2");
|
|
132
|
+
const job3 = yield* cache.get("job-3");
|
|
133
|
+
const job4 = yield* cache.get("job-4");
|
|
134
|
+
expect(job2).toEqual({ clientId: "user-2" });
|
|
135
|
+
expect(job3).toEqual({ clientId: "user-3" });
|
|
136
|
+
expect(job4).toEqual({ clientId: "user-4" });
|
|
137
|
+
});
|
|
138
|
+
await Effect.runPromise(program.pipe(Effect.provide(AuthCacheServiceLive({ maxSize }))));
|
|
139
|
+
});
|
|
140
|
+
it("should handle multiple auth contexts independently", async () => {
|
|
141
|
+
const authContext1 = {
|
|
142
|
+
clientId: "user-1",
|
|
143
|
+
metadata: { role: "admin" },
|
|
144
|
+
};
|
|
145
|
+
const authContext2 = {
|
|
146
|
+
clientId: "user-2",
|
|
147
|
+
permissions: ["read", "write"],
|
|
148
|
+
};
|
|
149
|
+
const program = Effect.gen(function* () {
|
|
150
|
+
const cache = yield* AuthCacheService;
|
|
151
|
+
yield* cache.set("upload-123", authContext1);
|
|
152
|
+
yield* cache.set("flow-456", authContext2);
|
|
153
|
+
const upload = yield* cache.get("upload-123");
|
|
154
|
+
const flow = yield* cache.get("flow-456");
|
|
155
|
+
expect(upload).toEqual(authContext1);
|
|
156
|
+
expect(flow).toEqual(authContext2);
|
|
157
|
+
});
|
|
158
|
+
await Effect.runPromise(program.pipe(Effect.provide(AuthCacheServiceLive())));
|
|
159
|
+
});
|
|
160
|
+
it("should update existing entry when setting same job ID", async () => {
|
|
161
|
+
const authContext1 = { clientId: "user-1" };
|
|
162
|
+
const authContext2 = { clientId: "user-2" };
|
|
163
|
+
const program = Effect.gen(function* () {
|
|
164
|
+
const cache = yield* AuthCacheService;
|
|
165
|
+
yield* cache.set("job-1", authContext1);
|
|
166
|
+
const first = yield* cache.get("job-1");
|
|
167
|
+
expect(first).toEqual(authContext1);
|
|
168
|
+
// Update with new auth context
|
|
169
|
+
yield* cache.set("job-1", authContext2);
|
|
170
|
+
const second = yield* cache.get("job-1");
|
|
171
|
+
expect(second).toEqual(authContext2);
|
|
172
|
+
// Should still be only 1 entry
|
|
173
|
+
const size = yield* cache.size();
|
|
174
|
+
expect(size).toBe(1);
|
|
175
|
+
});
|
|
176
|
+
await Effect.runPromise(program.pipe(Effect.provide(AuthCacheServiceLive())));
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
describe("NoAuthCacheServiceLive", () => {
|
|
180
|
+
it("should not cache anything", async () => {
|
|
181
|
+
const authContext = {
|
|
182
|
+
clientId: "user-123",
|
|
183
|
+
};
|
|
184
|
+
const program = Effect.gen(function* () {
|
|
185
|
+
const cache = yield* AuthCacheService;
|
|
186
|
+
// Try to set
|
|
187
|
+
yield* cache.set("job-1", authContext);
|
|
188
|
+
// Should always return null
|
|
189
|
+
const retrieved = yield* cache.get("job-1");
|
|
190
|
+
expect(retrieved).toBeNull();
|
|
191
|
+
// Size should always be 0
|
|
192
|
+
const size = yield* cache.size();
|
|
193
|
+
expect(size).toBe(0);
|
|
194
|
+
});
|
|
195
|
+
await Effect.runPromise(program.pipe(Effect.provide(NoAuthCacheServiceLive)));
|
|
196
|
+
});
|
|
197
|
+
it("should handle delete and clear without errors", async () => {
|
|
198
|
+
const program = Effect.gen(function* () {
|
|
199
|
+
const cache = yield* AuthCacheService;
|
|
200
|
+
// These should not throw
|
|
201
|
+
yield* cache.delete("non-existent");
|
|
202
|
+
yield* cache.clear();
|
|
203
|
+
const size = yield* cache.size();
|
|
204
|
+
expect(size).toBe(0);
|
|
205
|
+
});
|
|
206
|
+
await Effect.runPromise(program.pipe(Effect.provide(NoAuthCacheServiceLive)));
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|