@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.
@@ -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
+ }