@supaku/agentfactory-server 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/env-validation.d.ts +65 -0
- package/dist/src/env-validation.d.ts.map +1 -0
- package/dist/src/env-validation.js +134 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +16 -0
- package/dist/src/logger.d.ts +76 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +218 -0
- package/dist/src/orphan-cleanup.d.ts +64 -0
- package/dist/src/orphan-cleanup.d.ts.map +1 -0
- package/dist/src/orphan-cleanup.js +335 -0
- package/dist/src/pending-prompts.d.ts +67 -0
- package/dist/src/pending-prompts.d.ts.map +1 -0
- package/dist/src/pending-prompts.js +176 -0
- package/dist/src/rate-limit.d.ts +111 -0
- package/dist/src/rate-limit.d.ts.map +1 -0
- package/dist/src/rate-limit.js +171 -0
- package/dist/src/session-hash.d.ts +48 -0
- package/dist/src/session-hash.d.ts.map +1 -0
- package/dist/src/session-hash.js +80 -0
- package/dist/src/token-storage.d.ts +118 -0
- package/dist/src/token-storage.d.ts.map +1 -0
- package/dist/src/token-storage.js +263 -0
- package/dist/src/worker-auth.d.ts +29 -0
- package/dist/src/worker-auth.d.ts.map +1 -0
- package/dist/src/worker-auth.js +49 -0
- package/package.json +3 -3
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiter with LRU Cache
|
|
3
|
+
*
|
|
4
|
+
* In-memory rate limiting using sliding window algorithm.
|
|
5
|
+
* Uses LRU cache to prevent memory bloat from tracking many IPs.
|
|
6
|
+
*/
|
|
7
|
+
import { createLogger } from './logger';
|
|
8
|
+
const log = createLogger('rate-limit');
|
|
9
|
+
/**
|
|
10
|
+
* Default rate limit configurations by endpoint type
|
|
11
|
+
*/
|
|
12
|
+
export const RATE_LIMITS = {
|
|
13
|
+
/** Public API endpoints - 60 requests per minute */
|
|
14
|
+
public: { limit: 60, windowMs: 60 * 1000 },
|
|
15
|
+
/** Webhook endpoint - 10 requests per second per IP */
|
|
16
|
+
webhook: { limit: 10, windowMs: 1000 },
|
|
17
|
+
/** Dashboard - 30 requests per minute */
|
|
18
|
+
dashboard: { limit: 30, windowMs: 60 * 1000 },
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* LRU Rate Limiter
|
|
22
|
+
*
|
|
23
|
+
* Tracks request counts per key (typically IP address) using
|
|
24
|
+
* sliding window algorithm. Old entries are evicted using LRU policy.
|
|
25
|
+
*/
|
|
26
|
+
export class RateLimiter {
|
|
27
|
+
cache = new Map();
|
|
28
|
+
maxEntries;
|
|
29
|
+
config;
|
|
30
|
+
constructor(config, maxEntries = 10000) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
this.maxEntries = maxEntries;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Check if a request should be allowed
|
|
36
|
+
*
|
|
37
|
+
* @param key - Unique identifier (usually IP address)
|
|
38
|
+
* @returns Rate limit result
|
|
39
|
+
*/
|
|
40
|
+
check(key) {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const windowStart = now - this.config.windowMs;
|
|
43
|
+
// Get or create entry
|
|
44
|
+
let entry = this.cache.get(key);
|
|
45
|
+
if (!entry) {
|
|
46
|
+
entry = { timestamps: [], lastAccess: now };
|
|
47
|
+
}
|
|
48
|
+
// Filter timestamps to only include those in the current window
|
|
49
|
+
entry.timestamps = entry.timestamps.filter((ts) => ts > windowStart);
|
|
50
|
+
entry.lastAccess = now;
|
|
51
|
+
// Calculate remaining requests
|
|
52
|
+
const requestCount = entry.timestamps.length;
|
|
53
|
+
const remaining = Math.max(0, this.config.limit - requestCount);
|
|
54
|
+
const allowed = requestCount < this.config.limit;
|
|
55
|
+
// Add this request if allowed
|
|
56
|
+
if (allowed) {
|
|
57
|
+
entry.timestamps.push(now);
|
|
58
|
+
}
|
|
59
|
+
// Update cache
|
|
60
|
+
this.cache.set(key, entry);
|
|
61
|
+
// Evict old entries if needed
|
|
62
|
+
this.evictIfNeeded();
|
|
63
|
+
// Calculate reset time
|
|
64
|
+
const oldestTimestamp = entry.timestamps[0];
|
|
65
|
+
const resetIn = oldestTimestamp
|
|
66
|
+
? Math.max(0, oldestTimestamp + this.config.windowMs - now)
|
|
67
|
+
: 0;
|
|
68
|
+
return {
|
|
69
|
+
allowed,
|
|
70
|
+
remaining: allowed ? remaining - 1 : 0,
|
|
71
|
+
resetIn,
|
|
72
|
+
limit: this.config.limit,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Evict least recently used entries if cache is full
|
|
77
|
+
*/
|
|
78
|
+
evictIfNeeded() {
|
|
79
|
+
if (this.cache.size <= this.maxEntries)
|
|
80
|
+
return;
|
|
81
|
+
// Find entries to evict (oldest lastAccess)
|
|
82
|
+
const entries = Array.from(this.cache.entries());
|
|
83
|
+
entries.sort((a, b) => a[1].lastAccess - b[1].lastAccess);
|
|
84
|
+
// Remove oldest 10% of entries
|
|
85
|
+
const toRemove = Math.ceil(this.maxEntries * 0.1);
|
|
86
|
+
for (let i = 0; i < toRemove && i < entries.length; i++) {
|
|
87
|
+
this.cache.delete(entries[i][0]);
|
|
88
|
+
}
|
|
89
|
+
log.debug('Evicted rate limit entries', { removed: toRemove });
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Clear all entries (useful for testing)
|
|
93
|
+
*/
|
|
94
|
+
clear() {
|
|
95
|
+
this.cache.clear();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Get current cache size
|
|
99
|
+
*/
|
|
100
|
+
get size() {
|
|
101
|
+
return this.cache.size;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Singleton rate limiters for different endpoint types
|
|
105
|
+
const limiters = new Map();
|
|
106
|
+
/**
|
|
107
|
+
* Get or create a rate limiter for an endpoint type
|
|
108
|
+
*
|
|
109
|
+
* @param type - Endpoint type ('public', 'webhook', 'dashboard')
|
|
110
|
+
* @returns Rate limiter instance
|
|
111
|
+
*/
|
|
112
|
+
export function getRateLimiter(type) {
|
|
113
|
+
let limiter = limiters.get(type);
|
|
114
|
+
if (!limiter) {
|
|
115
|
+
limiter = new RateLimiter(RATE_LIMITS[type]);
|
|
116
|
+
limiters.set(type, limiter);
|
|
117
|
+
}
|
|
118
|
+
return limiter;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Check rate limit for a request
|
|
122
|
+
*
|
|
123
|
+
* @param type - Endpoint type
|
|
124
|
+
* @param key - Unique identifier (usually IP)
|
|
125
|
+
* @returns Rate limit result
|
|
126
|
+
*/
|
|
127
|
+
export function checkRateLimit(type, key) {
|
|
128
|
+
const limiter = getRateLimiter(type);
|
|
129
|
+
return limiter.check(key);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Extract client IP from request headers
|
|
133
|
+
*
|
|
134
|
+
* Handles various proxy scenarios (Vercel, Cloudflare, etc.)
|
|
135
|
+
*
|
|
136
|
+
* @param headers - Request headers
|
|
137
|
+
* @returns Client IP address
|
|
138
|
+
*/
|
|
139
|
+
export function getClientIP(headers) {
|
|
140
|
+
// Vercel/Cloudflare proxy headers
|
|
141
|
+
const forwardedFor = headers.get('x-forwarded-for');
|
|
142
|
+
if (forwardedFor) {
|
|
143
|
+
// Take first IP (client IP before proxies)
|
|
144
|
+
return forwardedFor.split(',')[0].trim();
|
|
145
|
+
}
|
|
146
|
+
// Cloudflare specific
|
|
147
|
+
const cfConnectingIP = headers.get('cf-connecting-ip');
|
|
148
|
+
if (cfConnectingIP) {
|
|
149
|
+
return cfConnectingIP;
|
|
150
|
+
}
|
|
151
|
+
// Vercel specific
|
|
152
|
+
const realIP = headers.get('x-real-ip');
|
|
153
|
+
if (realIP) {
|
|
154
|
+
return realIP;
|
|
155
|
+
}
|
|
156
|
+
// Fallback
|
|
157
|
+
return 'unknown';
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Build rate limit headers for response
|
|
161
|
+
*
|
|
162
|
+
* @param result - Rate limit result
|
|
163
|
+
* @returns Headers object
|
|
164
|
+
*/
|
|
165
|
+
export function buildRateLimitHeaders(result) {
|
|
166
|
+
return {
|
|
167
|
+
'X-RateLimit-Limit': result.limit.toString(),
|
|
168
|
+
'X-RateLimit-Remaining': result.remaining.toString(),
|
|
169
|
+
'X-RateLimit-Reset': Math.ceil(result.resetIn / 1000).toString(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Hash Utility
|
|
3
|
+
*
|
|
4
|
+
* Creates opaque, non-reversible session IDs for public API.
|
|
5
|
+
* Internal session IDs (Linear UUIDs) are hashed to prevent enumeration
|
|
6
|
+
* and hide internal structure from public viewers.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Create a public hash from a session ID
|
|
10
|
+
*
|
|
11
|
+
* Uses HMAC-SHA256 with a secret salt to create a consistent
|
|
12
|
+
* but non-reversible public identifier.
|
|
13
|
+
*
|
|
14
|
+
* @param sessionId - Internal Linear session ID
|
|
15
|
+
* @param saltEnvVar - Environment variable name for the salt (default: SESSION_HASH_SALT)
|
|
16
|
+
* @returns Hashed public ID (16 character hex string)
|
|
17
|
+
*/
|
|
18
|
+
export declare function hashSessionId(sessionId: string, saltEnvVar?: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Create a lookup map from session IDs to their hashes
|
|
21
|
+
*
|
|
22
|
+
* Useful for resolving hashed IDs back to sessions when
|
|
23
|
+
* you have the full list of sessions.
|
|
24
|
+
*
|
|
25
|
+
* @param sessionIds - Array of internal session IDs
|
|
26
|
+
* @returns Map of hash -> sessionId
|
|
27
|
+
*/
|
|
28
|
+
export declare function createHashLookup(sessionIds: string[]): Map<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* Find session ID by its public hash
|
|
31
|
+
*
|
|
32
|
+
* Since hashing is one-way, we need to hash all session IDs
|
|
33
|
+
* and compare. This is O(n) but acceptable for the expected
|
|
34
|
+
* number of sessions (typically < 100).
|
|
35
|
+
*
|
|
36
|
+
* @param publicId - Hashed public ID
|
|
37
|
+
* @param sessionIds - Array of internal session IDs to search
|
|
38
|
+
* @returns Matching session ID or null
|
|
39
|
+
*/
|
|
40
|
+
export declare function findSessionByHash(publicId: string, sessionIds: string[]): string | null;
|
|
41
|
+
/**
|
|
42
|
+
* Validate a public session ID format
|
|
43
|
+
*
|
|
44
|
+
* @param publicId - Potential public session ID
|
|
45
|
+
* @returns Whether the format is valid
|
|
46
|
+
*/
|
|
47
|
+
export declare function isValidPublicId(publicId: string): boolean;
|
|
48
|
+
//# sourceMappingURL=session-hash.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-hash.d.ts","sourceRoot":"","sources":["../../src/session-hash.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAiB5E;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAM1E;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAAE,GACnB,MAAM,GAAG,IAAI,CAOf;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGzD"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Hash Utility
|
|
3
|
+
*
|
|
4
|
+
* Creates opaque, non-reversible session IDs for public API.
|
|
5
|
+
* Internal session IDs (Linear UUIDs) are hashed to prevent enumeration
|
|
6
|
+
* and hide internal structure from public viewers.
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import { getSessionHashSalt, isSessionHashConfigured } from './env-validation';
|
|
10
|
+
/**
|
|
11
|
+
* Create a public hash from a session ID
|
|
12
|
+
*
|
|
13
|
+
* Uses HMAC-SHA256 with a secret salt to create a consistent
|
|
14
|
+
* but non-reversible public identifier.
|
|
15
|
+
*
|
|
16
|
+
* @param sessionId - Internal Linear session ID
|
|
17
|
+
* @param saltEnvVar - Environment variable name for the salt (default: SESSION_HASH_SALT)
|
|
18
|
+
* @returns Hashed public ID (16 character hex string)
|
|
19
|
+
*/
|
|
20
|
+
export function hashSessionId(sessionId, saltEnvVar) {
|
|
21
|
+
if (!isSessionHashConfigured()) {
|
|
22
|
+
// In development without salt, use truncated hash
|
|
23
|
+
// This is less secure but allows development without full config
|
|
24
|
+
return crypto
|
|
25
|
+
.createHash('sha256')
|
|
26
|
+
.update(sessionId)
|
|
27
|
+
.digest('hex')
|
|
28
|
+
.slice(0, 16);
|
|
29
|
+
}
|
|
30
|
+
const salt = getSessionHashSalt(saltEnvVar);
|
|
31
|
+
const hmac = crypto.createHmac('sha256', salt);
|
|
32
|
+
hmac.update(sessionId);
|
|
33
|
+
// Return first 16 characters for a reasonably short but unique ID
|
|
34
|
+
return hmac.digest('hex').slice(0, 16);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create a lookup map from session IDs to their hashes
|
|
38
|
+
*
|
|
39
|
+
* Useful for resolving hashed IDs back to sessions when
|
|
40
|
+
* you have the full list of sessions.
|
|
41
|
+
*
|
|
42
|
+
* @param sessionIds - Array of internal session IDs
|
|
43
|
+
* @returns Map of hash -> sessionId
|
|
44
|
+
*/
|
|
45
|
+
export function createHashLookup(sessionIds) {
|
|
46
|
+
const lookup = new Map();
|
|
47
|
+
for (const id of sessionIds) {
|
|
48
|
+
lookup.set(hashSessionId(id), id);
|
|
49
|
+
}
|
|
50
|
+
return lookup;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Find session ID by its public hash
|
|
54
|
+
*
|
|
55
|
+
* Since hashing is one-way, we need to hash all session IDs
|
|
56
|
+
* and compare. This is O(n) but acceptable for the expected
|
|
57
|
+
* number of sessions (typically < 100).
|
|
58
|
+
*
|
|
59
|
+
* @param publicId - Hashed public ID
|
|
60
|
+
* @param sessionIds - Array of internal session IDs to search
|
|
61
|
+
* @returns Matching session ID or null
|
|
62
|
+
*/
|
|
63
|
+
export function findSessionByHash(publicId, sessionIds) {
|
|
64
|
+
for (const id of sessionIds) {
|
|
65
|
+
if (hashSessionId(id) === publicId) {
|
|
66
|
+
return id;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Validate a public session ID format
|
|
73
|
+
*
|
|
74
|
+
* @param publicId - Potential public session ID
|
|
75
|
+
* @returns Whether the format is valid
|
|
76
|
+
*/
|
|
77
|
+
export function isValidPublicId(publicId) {
|
|
78
|
+
// Should be 16 character hex string
|
|
79
|
+
return /^[a-f0-9]{16}$/.test(publicId);
|
|
80
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Token Storage Module
|
|
3
|
+
*
|
|
4
|
+
* Manages Linear OAuth token lifecycle in Redis:
|
|
5
|
+
* - Store, retrieve, refresh, and revoke tokens
|
|
6
|
+
* - Automatic token refresh before expiration
|
|
7
|
+
* - Multi-workspace token support
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* OAuth token data stored in Redis
|
|
11
|
+
*/
|
|
12
|
+
export interface StoredToken {
|
|
13
|
+
/** The OAuth access token for API calls */
|
|
14
|
+
accessToken: string;
|
|
15
|
+
/** The refresh token for obtaining new access tokens */
|
|
16
|
+
refreshToken?: string;
|
|
17
|
+
/** Token type (usually "Bearer") */
|
|
18
|
+
tokenType: string;
|
|
19
|
+
/** OAuth scopes granted */
|
|
20
|
+
scope?: string;
|
|
21
|
+
/** Unix timestamp when the token expires */
|
|
22
|
+
expiresAt?: number;
|
|
23
|
+
/** Unix timestamp when the token was stored */
|
|
24
|
+
storedAt: number;
|
|
25
|
+
/** Workspace/organization ID this token belongs to */
|
|
26
|
+
workspaceId: string;
|
|
27
|
+
/** Workspace name for display purposes */
|
|
28
|
+
workspaceName?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Response from Linear OAuth token exchange
|
|
32
|
+
*/
|
|
33
|
+
export interface LinearTokenResponse {
|
|
34
|
+
access_token: string;
|
|
35
|
+
refresh_token?: string;
|
|
36
|
+
token_type: string;
|
|
37
|
+
expires_in?: number;
|
|
38
|
+
scope?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Organization info from Linear API
|
|
42
|
+
*/
|
|
43
|
+
export interface LinearOrganization {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
urlKey: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Store OAuth token for a workspace in Redis
|
|
50
|
+
*
|
|
51
|
+
* @param workspaceId - The Linear organization ID
|
|
52
|
+
* @param tokenResponse - The token data from OAuth exchange
|
|
53
|
+
* @param workspaceName - Optional workspace name for display
|
|
54
|
+
*/
|
|
55
|
+
export declare function storeToken(workspaceId: string, tokenResponse: LinearTokenResponse, workspaceName?: string): Promise<StoredToken>;
|
|
56
|
+
/**
|
|
57
|
+
* Retrieve OAuth token for a workspace from Redis
|
|
58
|
+
*
|
|
59
|
+
* @param workspaceId - The Linear organization ID
|
|
60
|
+
* @returns The stored token or null if not found
|
|
61
|
+
*/
|
|
62
|
+
export declare function getToken(workspaceId: string): Promise<StoredToken | null>;
|
|
63
|
+
/**
|
|
64
|
+
* Check if a token needs to be refreshed
|
|
65
|
+
* Returns true if token expires within the buffer period
|
|
66
|
+
*
|
|
67
|
+
* @param token - The stored token to check
|
|
68
|
+
* @returns Whether the token should be refreshed
|
|
69
|
+
*/
|
|
70
|
+
export declare function shouldRefreshToken(token: StoredToken): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Refresh an OAuth token using the refresh token
|
|
73
|
+
*
|
|
74
|
+
* @param token - The current stored token with refresh token
|
|
75
|
+
* @param clientId - The Linear OAuth client ID
|
|
76
|
+
* @param clientSecret - The Linear OAuth client secret
|
|
77
|
+
* @returns The new stored token or null if refresh failed
|
|
78
|
+
*/
|
|
79
|
+
export declare function refreshToken(token: StoredToken, clientId: string, clientSecret: string): Promise<StoredToken | null>;
|
|
80
|
+
/**
|
|
81
|
+
* Get a valid access token for a workspace, refreshing if necessary
|
|
82
|
+
*
|
|
83
|
+
* @param workspaceId - The Linear organization ID
|
|
84
|
+
* @param clientId - Optional OAuth client ID for refresh (defaults to env var)
|
|
85
|
+
* @param clientSecret - Optional OAuth client secret for refresh (defaults to env var)
|
|
86
|
+
* @returns The access token or null if not available
|
|
87
|
+
*/
|
|
88
|
+
export declare function getAccessToken(workspaceId: string, clientId?: string, clientSecret?: string): Promise<string | null>;
|
|
89
|
+
/**
|
|
90
|
+
* Delete a token from Redis (for cleanup or revocation)
|
|
91
|
+
*
|
|
92
|
+
* @param workspaceId - The Linear organization ID
|
|
93
|
+
* @returns Whether the deletion was successful
|
|
94
|
+
*/
|
|
95
|
+
export declare function deleteToken(workspaceId: string): Promise<boolean>;
|
|
96
|
+
/**
|
|
97
|
+
* List all stored workspace tokens (for admin purposes)
|
|
98
|
+
* Note: This scans all keys with the token prefix
|
|
99
|
+
*
|
|
100
|
+
* @returns Array of workspace IDs with stored tokens
|
|
101
|
+
*/
|
|
102
|
+
export declare function listStoredWorkspaces(): Promise<string[]>;
|
|
103
|
+
/**
|
|
104
|
+
* Clean up expired tokens from Redis storage
|
|
105
|
+
* Should be called periodically (e.g., via cron job)
|
|
106
|
+
*
|
|
107
|
+
* @returns Number of tokens cleaned up
|
|
108
|
+
*/
|
|
109
|
+
export declare function cleanupExpiredTokens(): Promise<number>;
|
|
110
|
+
/**
|
|
111
|
+
* Fetch the current user's organization from Linear API
|
|
112
|
+
* Used after OAuth to determine which workspace the token belongs to
|
|
113
|
+
*
|
|
114
|
+
* @param accessToken - The OAuth access token
|
|
115
|
+
* @returns Organization info or null if fetch failed
|
|
116
|
+
*/
|
|
117
|
+
export declare function fetchOrganization(accessToken: string): Promise<LinearOrganization | null>;
|
|
118
|
+
//# sourceMappingURL=token-storage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-storage.d.ts","sourceRoot":"","sources":["../../src/token-storage.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAOH;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,2CAA2C;IAC3C,WAAW,EAAE,MAAM,CAAA;IACnB,wDAAwD;IACxD,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAA;IACjB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,CAAA;IAChB,sDAAsD;IACtD,WAAW,EAAE,MAAM,CAAA;IACnB,0CAA0C;IAC1C,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,MAAM,CAAA;IACpB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;CACf;AAoBD;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,mBAAmB,EAClC,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,WAAW,CAAC,CAsBtB;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAU/E;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAU9D;AAED;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,KAAK,EAAE,WAAW,EAClB,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAgD7B;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,WAAW,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,MAAM,EACjB,YAAY,CAAC,EAAE,MAAM,GACpB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA8BxB;AAED;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAYvE;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAQ9D;AAED;;;;;GAKG;AACH,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,MAAM,CAAC,CAsB5D;AAED;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CA6CpC"}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Token Storage Module
|
|
3
|
+
*
|
|
4
|
+
* Manages Linear OAuth token lifecycle in Redis:
|
|
5
|
+
* - Store, retrieve, refresh, and revoke tokens
|
|
6
|
+
* - Automatic token refresh before expiration
|
|
7
|
+
* - Multi-workspace token support
|
|
8
|
+
*/
|
|
9
|
+
import { createLogger } from './logger';
|
|
10
|
+
import { isRedisConfigured, redisSet, redisGet, redisDel, redisKeys } from './redis';
|
|
11
|
+
const log = createLogger('token-storage');
|
|
12
|
+
/**
|
|
13
|
+
* Key prefix for workspace tokens in KV
|
|
14
|
+
*/
|
|
15
|
+
const TOKEN_KEY_PREFIX = 'oauth:workspace:';
|
|
16
|
+
/**
|
|
17
|
+
* Buffer time (in seconds) before expiration to trigger refresh
|
|
18
|
+
* Refresh tokens 5 minutes before they expire
|
|
19
|
+
*/
|
|
20
|
+
const REFRESH_BUFFER_SECONDS = 5 * 60;
|
|
21
|
+
/**
|
|
22
|
+
* Build the KV key for a workspace token
|
|
23
|
+
*/
|
|
24
|
+
function buildTokenKey(workspaceId) {
|
|
25
|
+
return `${TOKEN_KEY_PREFIX}${workspaceId}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Store OAuth token for a workspace in Redis
|
|
29
|
+
*
|
|
30
|
+
* @param workspaceId - The Linear organization ID
|
|
31
|
+
* @param tokenResponse - The token data from OAuth exchange
|
|
32
|
+
* @param workspaceName - Optional workspace name for display
|
|
33
|
+
*/
|
|
34
|
+
export async function storeToken(workspaceId, tokenResponse, workspaceName) {
|
|
35
|
+
const now = Math.floor(Date.now() / 1000);
|
|
36
|
+
const storedToken = {
|
|
37
|
+
accessToken: tokenResponse.access_token,
|
|
38
|
+
refreshToken: tokenResponse.refresh_token,
|
|
39
|
+
tokenType: tokenResponse.token_type,
|
|
40
|
+
scope: tokenResponse.scope,
|
|
41
|
+
expiresAt: tokenResponse.expires_in
|
|
42
|
+
? now + tokenResponse.expires_in
|
|
43
|
+
: undefined,
|
|
44
|
+
storedAt: now,
|
|
45
|
+
workspaceId,
|
|
46
|
+
workspaceName,
|
|
47
|
+
};
|
|
48
|
+
const key = buildTokenKey(workspaceId);
|
|
49
|
+
await redisSet(key, storedToken);
|
|
50
|
+
log.info('Stored OAuth token', { workspaceId, workspaceName });
|
|
51
|
+
return storedToken;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Retrieve OAuth token for a workspace from Redis
|
|
55
|
+
*
|
|
56
|
+
* @param workspaceId - The Linear organization ID
|
|
57
|
+
* @returns The stored token or null if not found
|
|
58
|
+
*/
|
|
59
|
+
export async function getToken(workspaceId) {
|
|
60
|
+
if (!isRedisConfigured()) {
|
|
61
|
+
log.warn('Redis not configured, cannot retrieve token');
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const key = buildTokenKey(workspaceId);
|
|
65
|
+
const token = await redisGet(key);
|
|
66
|
+
return token;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Check if a token needs to be refreshed
|
|
70
|
+
* Returns true if token expires within the buffer period
|
|
71
|
+
*
|
|
72
|
+
* @param token - The stored token to check
|
|
73
|
+
* @returns Whether the token should be refreshed
|
|
74
|
+
*/
|
|
75
|
+
export function shouldRefreshToken(token) {
|
|
76
|
+
// No expiration means token doesn't expire (Linear API tokens typically don't)
|
|
77
|
+
if (!token.expiresAt) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const now = Math.floor(Date.now() / 1000);
|
|
81
|
+
const timeUntilExpiry = token.expiresAt - now;
|
|
82
|
+
return timeUntilExpiry <= REFRESH_BUFFER_SECONDS;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Refresh an OAuth token using the refresh token
|
|
86
|
+
*
|
|
87
|
+
* @param token - The current stored token with refresh token
|
|
88
|
+
* @param clientId - The Linear OAuth client ID
|
|
89
|
+
* @param clientSecret - The Linear OAuth client secret
|
|
90
|
+
* @returns The new stored token or null if refresh failed
|
|
91
|
+
*/
|
|
92
|
+
export async function refreshToken(token, clientId, clientSecret) {
|
|
93
|
+
const workspaceId = token.workspaceId;
|
|
94
|
+
if (!token.refreshToken) {
|
|
95
|
+
log.warn('No refresh token available', { workspaceId });
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch('https://api.linear.app/oauth/token', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: {
|
|
102
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
103
|
+
},
|
|
104
|
+
body: new URLSearchParams({
|
|
105
|
+
grant_type: 'refresh_token',
|
|
106
|
+
client_id: clientId,
|
|
107
|
+
client_secret: clientSecret,
|
|
108
|
+
refresh_token: token.refreshToken,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
const errorText = await response.text();
|
|
113
|
+
log.error('Token refresh failed', {
|
|
114
|
+
workspaceId,
|
|
115
|
+
statusCode: response.status,
|
|
116
|
+
errorDetails: errorText,
|
|
117
|
+
});
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const tokenResponse = (await response.json());
|
|
121
|
+
// Store the new token
|
|
122
|
+
const newToken = await storeToken(token.workspaceId, tokenResponse, token.workspaceName);
|
|
123
|
+
log.info('Refreshed OAuth token', { workspaceId });
|
|
124
|
+
return newToken;
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
log.error('Token refresh error', { workspaceId, error: err });
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get a valid access token for a workspace, refreshing if necessary
|
|
133
|
+
*
|
|
134
|
+
* @param workspaceId - The Linear organization ID
|
|
135
|
+
* @param clientId - Optional OAuth client ID for refresh (defaults to env var)
|
|
136
|
+
* @param clientSecret - Optional OAuth client secret for refresh (defaults to env var)
|
|
137
|
+
* @returns The access token or null if not available
|
|
138
|
+
*/
|
|
139
|
+
export async function getAccessToken(workspaceId, clientId, clientSecret) {
|
|
140
|
+
const token = await getToken(workspaceId);
|
|
141
|
+
if (!token) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
// Check if token needs refresh
|
|
145
|
+
if (shouldRefreshToken(token)) {
|
|
146
|
+
const cid = clientId ?? process.env.LINEAR_CLIENT_ID;
|
|
147
|
+
const csecret = clientSecret ?? process.env.LINEAR_CLIENT_SECRET;
|
|
148
|
+
if (!cid || !csecret) {
|
|
149
|
+
log.warn('OAuth credentials not configured, cannot refresh token', { workspaceId });
|
|
150
|
+
// Return existing token even if it might be expiring soon
|
|
151
|
+
return token.accessToken;
|
|
152
|
+
}
|
|
153
|
+
const refreshedToken = await refreshToken(token, cid, csecret);
|
|
154
|
+
if (refreshedToken) {
|
|
155
|
+
return refreshedToken.accessToken;
|
|
156
|
+
}
|
|
157
|
+
// Refresh failed, return existing token
|
|
158
|
+
log.warn('Token refresh failed, using existing token', { workspaceId });
|
|
159
|
+
return token.accessToken;
|
|
160
|
+
}
|
|
161
|
+
return token.accessToken;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Delete a token from Redis (for cleanup or revocation)
|
|
165
|
+
*
|
|
166
|
+
* @param workspaceId - The Linear organization ID
|
|
167
|
+
* @returns Whether the deletion was successful
|
|
168
|
+
*/
|
|
169
|
+
export async function deleteToken(workspaceId) {
|
|
170
|
+
if (!isRedisConfigured()) {
|
|
171
|
+
log.warn('Redis not configured, cannot delete token');
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
const key = buildTokenKey(workspaceId);
|
|
175
|
+
const result = await redisDel(key);
|
|
176
|
+
log.info('Deleted OAuth token', { workspaceId });
|
|
177
|
+
return result > 0;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* List all stored workspace tokens (for admin purposes)
|
|
181
|
+
* Note: This scans all keys with the token prefix
|
|
182
|
+
*
|
|
183
|
+
* @returns Array of workspace IDs with stored tokens
|
|
184
|
+
*/
|
|
185
|
+
export async function listStoredWorkspaces() {
|
|
186
|
+
if (!isRedisConfigured()) {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
const keys = await redisKeys(`${TOKEN_KEY_PREFIX}*`);
|
|
190
|
+
return keys.map((key) => key.replace(TOKEN_KEY_PREFIX, ''));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Clean up expired tokens from Redis storage
|
|
194
|
+
* Should be called periodically (e.g., via cron job)
|
|
195
|
+
*
|
|
196
|
+
* @returns Number of tokens cleaned up
|
|
197
|
+
*/
|
|
198
|
+
export async function cleanupExpiredTokens() {
|
|
199
|
+
if (!isRedisConfigured()) {
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
const workspaces = await listStoredWorkspaces();
|
|
203
|
+
const now = Math.floor(Date.now() / 1000);
|
|
204
|
+
let cleanedCount = 0;
|
|
205
|
+
for (const workspaceId of workspaces) {
|
|
206
|
+
const token = await getToken(workspaceId);
|
|
207
|
+
// Remove tokens that have expired (with some grace period)
|
|
208
|
+
// We add 1 hour grace period to avoid removing tokens that might still be usable
|
|
209
|
+
if (token?.expiresAt && token.expiresAt + 3600 < now) {
|
|
210
|
+
await deleteToken(workspaceId);
|
|
211
|
+
cleanedCount++;
|
|
212
|
+
log.info('Cleaned up expired token', { workspaceId });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return cleanedCount;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Fetch the current user's organization from Linear API
|
|
219
|
+
* Used after OAuth to determine which workspace the token belongs to
|
|
220
|
+
*
|
|
221
|
+
* @param accessToken - The OAuth access token
|
|
222
|
+
* @returns Organization info or null if fetch failed
|
|
223
|
+
*/
|
|
224
|
+
export async function fetchOrganization(accessToken) {
|
|
225
|
+
try {
|
|
226
|
+
const response = await fetch('https://api.linear.app/graphql', {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: {
|
|
229
|
+
'Content-Type': 'application/json',
|
|
230
|
+
Authorization: `Bearer ${accessToken}`,
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify({
|
|
233
|
+
query: `
|
|
234
|
+
query {
|
|
235
|
+
organization {
|
|
236
|
+
id
|
|
237
|
+
name
|
|
238
|
+
urlKey
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
`,
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
const errorText = await response.text();
|
|
246
|
+
log.error('Failed to fetch organization', {
|
|
247
|
+
statusCode: response.status,
|
|
248
|
+
errorDetails: errorText,
|
|
249
|
+
});
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const data = (await response.json());
|
|
253
|
+
if (data.errors) {
|
|
254
|
+
log.error('GraphQL errors fetching organization', { errors: data.errors });
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
return data.data?.organization ?? null;
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
log.error('Error fetching organization', { error: err });
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|