@tuturuuu/utils 0.0.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
@@ -0,0 +1,124 @@
1
+ export type UserAgentRiskLevel = 'allow' | 'block';
2
+
3
+ export interface UserAgentClassification {
4
+ matchedPattern: string | null;
5
+ normalizedUserAgent: string | null;
6
+ reason: string | null;
7
+ riskLevel: UserAgentRiskLevel;
8
+ }
9
+
10
+ const NATIVE_APP_SIGNATURE_REGEX =
11
+ /\b(?:cfnetwork|dart(?:\/|\s)|dart:io|dalvik|flutter|okhttp)\b/i;
12
+ const KNOWN_AUTOMATION_REGEX =
13
+ /\b(?:headlesschrome|phantomjs|puppeteer|playwright|selenium|webdriver)\b/i;
14
+ const KNOWN_SCRIPT_CLIENT_REGEX =
15
+ /\b(?:axios\/|curl\/|go-http-client|java\/|node-fetch|python-requests|wget\/)\b/i;
16
+ const KNOWN_CRAWLER_REGEX = /\b(?:bot|crawler|scrapy|spider)\b/i;
17
+ const BROWSER_SIGNATURE_REGEX =
18
+ /\b(?:applewebkit|chrome\/|edg\/|firefox\/|gecko\/|mozilla\/5\.0|opr\/|safari\/|version\/)\b/i;
19
+
20
+ function normalizeUserAgent(value?: string | null) {
21
+ if (typeof value !== 'string') {
22
+ return null;
23
+ }
24
+
25
+ const normalized = value.trim();
26
+ return normalized.length > 0 ? normalized : null;
27
+ }
28
+
29
+ export function extractUserAgentFromHeaders(
30
+ headers: Headers | Map<string, string> | Record<string, string | null>
31
+ ) {
32
+ if (headers instanceof Headers) {
33
+ return normalizeUserAgent(headers.get('user-agent'));
34
+ }
35
+
36
+ if (headers instanceof Map) {
37
+ return normalizeUserAgent(headers.get('user-agent') || null);
38
+ }
39
+
40
+ return normalizeUserAgent(headers['user-agent']);
41
+ }
42
+
43
+ export function classifyPotentialSpamUserAgent(
44
+ userAgent?: string | null,
45
+ options: { allowNativeAppUserAgents?: boolean } = {}
46
+ ): UserAgentClassification {
47
+ const normalizedUserAgent = normalizeUserAgent(userAgent);
48
+
49
+ if (!normalizedUserAgent) {
50
+ return {
51
+ matchedPattern: null,
52
+ normalizedUserAgent,
53
+ reason: 'missing_user_agent',
54
+ riskLevel: 'block',
55
+ };
56
+ }
57
+
58
+ if (
59
+ options.allowNativeAppUserAgents &&
60
+ NATIVE_APP_SIGNATURE_REGEX.test(normalizedUserAgent)
61
+ ) {
62
+ return {
63
+ matchedPattern: null,
64
+ normalizedUserAgent,
65
+ reason: null,
66
+ riskLevel: 'allow',
67
+ };
68
+ }
69
+
70
+ const blockedMatchers = [
71
+ {
72
+ reason: 'known_automation_framework',
73
+ regex: KNOWN_AUTOMATION_REGEX,
74
+ },
75
+ {
76
+ reason: 'scripted_http_client',
77
+ regex: KNOWN_SCRIPT_CLIENT_REGEX,
78
+ },
79
+ {
80
+ reason: 'known_crawler_agent',
81
+ regex: KNOWN_CRAWLER_REGEX,
82
+ },
83
+ ] as const;
84
+
85
+ for (const matcher of blockedMatchers) {
86
+ const match = normalizedUserAgent.match(matcher.regex);
87
+ if (match) {
88
+ return {
89
+ matchedPattern: match[0] || null,
90
+ normalizedUserAgent,
91
+ reason: matcher.reason,
92
+ riskLevel: 'block',
93
+ };
94
+ }
95
+ }
96
+
97
+ if (normalizedUserAgent.length < 20) {
98
+ return {
99
+ matchedPattern: null,
100
+ normalizedUserAgent,
101
+ reason: 'short_user_agent',
102
+ riskLevel: 'block',
103
+ };
104
+ }
105
+
106
+ if (
107
+ normalizedUserAgent.startsWith('Mozilla/5.0') &&
108
+ !BROWSER_SIGNATURE_REGEX.test(normalizedUserAgent)
109
+ ) {
110
+ return {
111
+ matchedPattern: 'Mozilla/5.0',
112
+ normalizedUserAgent,
113
+ reason: 'malformed_browser_claim',
114
+ riskLevel: 'block',
115
+ };
116
+ }
117
+
118
+ return {
119
+ matchedPattern: null,
120
+ normalizedUserAgent,
121
+ reason: null,
122
+ riskLevel: 'allow',
123
+ };
124
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * User suspension utilities.
3
+ * Checks and manages user account suspensions.
4
+ */
5
+
6
+ import { getUpstashRestRedisClient } from '../upstash-rest';
7
+ import type { RedisClient } from './types';
8
+
9
+ // Extend REDIS_KEYS locally (avoids modifying shared constants for this feature)
10
+ const SUSPENSION_KEYS = {
11
+ USER_SUSPENDED: (userId: string) => `user:suspended:${userId}`,
12
+ } as const;
13
+
14
+ interface SuspensionResult {
15
+ suspended: boolean;
16
+ reason?: string;
17
+ expiresAt?: Date;
18
+ }
19
+
20
+ // Lazy-loaded Redis client
21
+ let redisClient: RedisClient | null = null;
22
+ let redisInitialized = false;
23
+
24
+ async function getRedisClient(): Promise<RedisClient | null> {
25
+ if (redisInitialized) return redisClient;
26
+
27
+ try {
28
+ redisClient = await getUpstashRestRedisClient();
29
+ redisInitialized = true;
30
+ return redisClient;
31
+ } catch {
32
+ redisInitialized = true;
33
+ return null;
34
+ }
35
+ }
36
+
37
+ async function getSupabaseAdmin() {
38
+ try {
39
+ const { createAdminClient } = await import(
40
+ '@tuturuuu/supabase/next/server'
41
+ );
42
+ return await createAdminClient();
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Check if a user is currently suspended.
50
+ * Checks Redis cache first (60s TTL), then falls back to DB.
51
+ */
52
+ export async function checkUserSuspension(
53
+ userId: string
54
+ ): Promise<SuspensionResult> {
55
+ const redis = await getRedisClient();
56
+
57
+ // Check Redis cache first
58
+ if (redis) {
59
+ try {
60
+ const cached = await redis.get<string>(
61
+ SUSPENSION_KEYS.USER_SUSPENDED(userId)
62
+ );
63
+ if (cached === 'not_suspended') {
64
+ return { suspended: false };
65
+ }
66
+ if (cached && cached !== 'not_suspended') {
67
+ const data = typeof cached === 'string' ? JSON.parse(cached) : cached;
68
+ if (!data.expiresAt || new Date(data.expiresAt) > new Date()) {
69
+ return {
70
+ suspended: true,
71
+ reason: data.reason,
72
+ expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,
73
+ };
74
+ }
75
+ }
76
+ } catch {
77
+ // Fall through to DB check
78
+ }
79
+ }
80
+
81
+ // Check database
82
+ try {
83
+ const sbAdmin = await getSupabaseAdmin();
84
+ if (!sbAdmin) return { suspended: false };
85
+
86
+ const { data, error } = await sbAdmin
87
+ .from('user_suspensions')
88
+ .select('id, reason, expires_at')
89
+ .eq('user_id', userId)
90
+ .is('lifted_at', null)
91
+ .or('expires_at.is.null,expires_at.gt.now()')
92
+ .order('suspended_at', { ascending: false })
93
+ .limit(1)
94
+ .maybeSingle();
95
+
96
+ if (error || !data) {
97
+ // Cache negative result
98
+ if (redis) {
99
+ await redis.set(
100
+ SUSPENSION_KEYS.USER_SUSPENDED(userId),
101
+ 'not_suspended',
102
+ { ex: 60 }
103
+ );
104
+ }
105
+ return { suspended: false };
106
+ }
107
+
108
+ const result: SuspensionResult = {
109
+ suspended: true,
110
+ reason: data.reason,
111
+ expiresAt: data.expires_at ? new Date(data.expires_at) : undefined,
112
+ };
113
+
114
+ // Cache positive result
115
+ if (redis) {
116
+ const ttl = data.expires_at
117
+ ? Math.min(
118
+ 60,
119
+ Math.ceil((new Date(data.expires_at).getTime() - Date.now()) / 1000)
120
+ )
121
+ : 60;
122
+ await redis.set(
123
+ SUSPENSION_KEYS.USER_SUSPENDED(userId),
124
+ JSON.stringify({
125
+ reason: data.reason,
126
+ expiresAt: data.expires_at,
127
+ }),
128
+ { ex: Math.max(1, ttl) }
129
+ );
130
+ }
131
+
132
+ return result;
133
+ } catch {
134
+ // Fail-open: if check fails, don't block user
135
+ return { suspended: false };
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Suspend a user account.
141
+ */
142
+ export async function suspendUser(
143
+ userId: string,
144
+ reason: string,
145
+ suspendedBy: string | null,
146
+ expiresAt?: Date
147
+ ): Promise<boolean> {
148
+ try {
149
+ const sbAdmin = await getSupabaseAdmin();
150
+ if (!sbAdmin) return false;
151
+
152
+ const { error } = await sbAdmin.from('user_suspensions').insert({
153
+ user_id: userId,
154
+ reason,
155
+ suspended_by: suspendedBy,
156
+ expires_at: expiresAt?.toISOString() ?? null,
157
+ });
158
+
159
+ if (error) {
160
+ console.error('[User Suspension] Error suspending user:', error);
161
+ return false;
162
+ }
163
+
164
+ // Update Redis cache
165
+ const redis = await getRedisClient();
166
+ if (redis) {
167
+ const ttl = expiresAt
168
+ ? Math.ceil((expiresAt.getTime() - Date.now()) / 1000)
169
+ : 60;
170
+ await redis.set(
171
+ SUSPENSION_KEYS.USER_SUSPENDED(userId),
172
+ JSON.stringify({
173
+ reason,
174
+ expiresAt: expiresAt?.toISOString() ?? null,
175
+ }),
176
+ { ex: Math.max(1, ttl) }
177
+ );
178
+ }
179
+
180
+ return true;
181
+ } catch (error) {
182
+ console.error('[User Suspension] Error:', error);
183
+ return false;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Lift a user suspension.
189
+ */
190
+ export async function liftSuspension(
191
+ suspensionId: string,
192
+ liftedBy: string
193
+ ): Promise<boolean> {
194
+ try {
195
+ const sbAdmin = await getSupabaseAdmin();
196
+ if (!sbAdmin) return false;
197
+
198
+ // Get the suspension to find the user_id
199
+ const { data: suspension } = await sbAdmin
200
+ .from('user_suspensions')
201
+ .select('user_id')
202
+ .eq('id', suspensionId)
203
+ .single();
204
+
205
+ if (!suspension) return false;
206
+
207
+ const { error } = await sbAdmin
208
+ .from('user_suspensions')
209
+ .update({
210
+ lifted_at: new Date().toISOString(),
211
+ lifted_by: liftedBy,
212
+ })
213
+ .eq('id', suspensionId);
214
+
215
+ if (error) {
216
+ console.error('[User Suspension] Error lifting suspension:', error);
217
+ return false;
218
+ }
219
+
220
+ // Clear Redis cache so next check hits DB
221
+ const redis = await getRedisClient();
222
+ if (redis) {
223
+ await redis.del(SUSPENSION_KEYS.USER_SUSPENDED(suspension.user_id));
224
+ }
225
+
226
+ return true;
227
+ } catch (error) {
228
+ console.error('[User Suspension] Error:', error);
229
+ return false;
230
+ }
231
+ }
@@ -0,0 +1,315 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { getUpstashRestRedisClient } from './upstash-rest';
3
+
4
+ export const AI_TEMP_AUTH_HEADER = 'x-tuturuuu-ai-temp-auth';
5
+ export const AI_TEMP_AUTH_TTL_SECONDS = 60;
6
+ export const AI_CREDIT_SNAPSHOT_TTL_SECONDS = 15;
7
+
8
+ const USER_VERSION_TTL_SECONDS = 24 * 60 * 60;
9
+ const CREDIT_SNAPSHOT_MAX_AGE_MS = AI_CREDIT_SNAPSHOT_TTL_SECONDS * 1000;
10
+ const MINIMUM_SNAPSHOT_REMAINING_CREDITS = 50;
11
+
12
+ type AiTempAuthUser = {
13
+ id: string;
14
+ email?: string | null;
15
+ };
16
+
17
+ export type AiTempAuthContext = {
18
+ user: AiTempAuthUser;
19
+ wsId?: string;
20
+ creditWsId?: string;
21
+ creditSource?: 'personal' | 'workspace';
22
+ };
23
+
24
+ type AiTempAuthPayload = AiTempAuthContext & {
25
+ tokenId: string;
26
+ authVersion: string;
27
+ issuedAt: number;
28
+ expiresAt: number;
29
+ };
30
+
31
+ export type AiTempAuthValidationResult =
32
+ | { status: 'missing' }
33
+ | { status: 'unavailable' }
34
+ | { status: 'invalid' }
35
+ | { status: 'expired' }
36
+ | { status: 'revoked' }
37
+ | { status: 'valid'; context: AiTempAuthContext };
38
+
39
+ export type AiCreditSnapshot = {
40
+ remainingCredits: number;
41
+ maxOutputTokens: number | null;
42
+ tier: string;
43
+ allowedModels: string[];
44
+ allowedFeatures: string[];
45
+ dailyLimit: number | null;
46
+ updatedAt: number;
47
+ };
48
+
49
+ function tokenKey(digest: string) {
50
+ return `ai:temp-auth:token:${digest}`;
51
+ }
52
+
53
+ function userVersionKey(userId: string) {
54
+ return `ai:temp-auth:user-version:${userId}`;
55
+ }
56
+
57
+ function creditSnapshotKey({ wsId, userId }: { wsId: string; userId: string }) {
58
+ return `ai:credits:snapshot:${wsId}:${userId}`;
59
+ }
60
+
61
+ function creditInFlightKey({ wsId, userId }: { wsId: string; userId: string }) {
62
+ return `ai:credits:in-flight:${wsId}:${userId}`;
63
+ }
64
+
65
+ function digestToken(token: string) {
66
+ return createHash('sha256').update(token).digest('hex');
67
+ }
68
+
69
+ function getBearerToken(request: Pick<Request, 'headers'>) {
70
+ const value = request.headers.get(AI_TEMP_AUTH_HEADER);
71
+ return value?.trim() || null;
72
+ }
73
+
74
+ function isPayload(value: unknown): value is AiTempAuthPayload {
75
+ if (!value || typeof value !== 'object') return false;
76
+ const payload = value as Partial<AiTempAuthPayload>;
77
+
78
+ return (
79
+ typeof payload.tokenId === 'string' &&
80
+ typeof payload.authVersion === 'string' &&
81
+ typeof payload.issuedAt === 'number' &&
82
+ typeof payload.expiresAt === 'number' &&
83
+ !!payload.user &&
84
+ typeof payload.user === 'object' &&
85
+ typeof payload.user.id === 'string'
86
+ );
87
+ }
88
+
89
+ export async function mintAiTempAuthToken({
90
+ user,
91
+ wsId,
92
+ creditWsId,
93
+ creditSource,
94
+ }: AiTempAuthContext): Promise<{
95
+ token: string;
96
+ expiresAt: number;
97
+ } | null> {
98
+ const redis = await getUpstashRestRedisClient().catch(() => null);
99
+ if (!redis) return null;
100
+
101
+ const authVersion =
102
+ (
103
+ await redis.get<string>(userVersionKey(user.id)).catch(() => null)
104
+ )?.toString() ?? '0';
105
+ const tokenId = randomUUID();
106
+ const secret = randomBytes(32).toString('base64url');
107
+ const token = `${tokenId}.${secret}`;
108
+ const now = Date.now();
109
+ const expiresAt = now + AI_TEMP_AUTH_TTL_SECONDS * 1000;
110
+
111
+ const payload: AiTempAuthPayload = {
112
+ tokenId,
113
+ user: { id: user.id, email: user.email ?? null },
114
+ ...(wsId ? { wsId } : {}),
115
+ ...(creditWsId ? { creditWsId } : {}),
116
+ ...(creditSource ? { creditSource } : {}),
117
+ authVersion,
118
+ issuedAt: now,
119
+ expiresAt,
120
+ };
121
+
122
+ try {
123
+ await redis.set(tokenKey(digestToken(token)), payload, {
124
+ ex: AI_TEMP_AUTH_TTL_SECONDS,
125
+ });
126
+ } catch {
127
+ return null;
128
+ }
129
+
130
+ return { token, expiresAt };
131
+ }
132
+
133
+ export async function validateAiTempAuthRequest(
134
+ request: Pick<Request, 'headers'>
135
+ ): Promise<AiTempAuthValidationResult> {
136
+ const token = getBearerToken(request);
137
+ if (!token) return { status: 'missing' };
138
+
139
+ const redis = await getUpstashRestRedisClient().catch(() => null);
140
+ if (!redis) return { status: 'unavailable' };
141
+
142
+ const payload = await redis
143
+ .get<AiTempAuthPayload>(tokenKey(digestToken(token)))
144
+ .catch(() => null);
145
+ if (!isPayload(payload)) return { status: 'invalid' };
146
+
147
+ if (payload.expiresAt <= Date.now()) {
148
+ return { status: 'expired' };
149
+ }
150
+
151
+ const currentVersion =
152
+ (
153
+ await redis.get<string>(userVersionKey(payload.user.id)).catch(() => null)
154
+ )?.toString() ?? '0';
155
+ if (currentVersion !== payload.authVersion) {
156
+ return { status: 'revoked' };
157
+ }
158
+
159
+ return {
160
+ status: 'valid',
161
+ context: {
162
+ user: payload.user,
163
+ ...(payload.wsId ? { wsId: payload.wsId } : {}),
164
+ ...(payload.creditWsId ? { creditWsId: payload.creditWsId } : {}),
165
+ ...(payload.creditSource ? { creditSource: payload.creditSource } : {}),
166
+ },
167
+ };
168
+ }
169
+
170
+ export async function revokeUserAiTempAuthTokens(
171
+ userId: string
172
+ ): Promise<boolean> {
173
+ const redis = await getUpstashRestRedisClient().catch(() => null);
174
+ if (!redis) return false;
175
+
176
+ try {
177
+ await redis.incr(userVersionKey(userId));
178
+ await redis.expire(userVersionKey(userId), USER_VERSION_TTL_SECONDS);
179
+ return true;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ export async function writeAiCreditSnapshot({
186
+ wsId,
187
+ userId,
188
+ snapshot,
189
+ }: {
190
+ wsId: string;
191
+ userId: string;
192
+ snapshot: AiCreditSnapshot;
193
+ }): Promise<boolean> {
194
+ const redis = await getUpstashRestRedisClient().catch(() => null);
195
+ if (!redis) return false;
196
+
197
+ try {
198
+ await redis.set(creditSnapshotKey({ wsId, userId }), snapshot, {
199
+ ex: AI_CREDIT_SNAPSHOT_TTL_SECONDS,
200
+ });
201
+ return true;
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ export async function readAiCreditSnapshot({
208
+ wsId,
209
+ userId,
210
+ }: {
211
+ wsId: string;
212
+ userId: string;
213
+ }): Promise<AiCreditSnapshot | null> {
214
+ const redis = await getUpstashRestRedisClient().catch(() => null);
215
+ if (!redis) return null;
216
+
217
+ const snapshot = await redis
218
+ .get<AiCreditSnapshot>(creditSnapshotKey({ wsId, userId }))
219
+ .catch(() => null);
220
+ return snapshot ?? null;
221
+ }
222
+
223
+ export async function invalidateAiCreditSnapshot({
224
+ wsId,
225
+ userId,
226
+ }: {
227
+ wsId: string;
228
+ userId: string;
229
+ }): Promise<boolean> {
230
+ const redis = await getUpstashRestRedisClient().catch(() => null);
231
+ if (!redis) return false;
232
+
233
+ try {
234
+ await redis.del(creditSnapshotKey({ wsId, userId }));
235
+ return true;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+
241
+ export async function incrementAiCreditChargeInFlight({
242
+ wsId,
243
+ userId,
244
+ }: {
245
+ wsId: string;
246
+ userId: string;
247
+ }): Promise<boolean> {
248
+ const redis = await getUpstashRestRedisClient().catch(() => null);
249
+ if (!redis) return false;
250
+
251
+ try {
252
+ const key = creditInFlightKey({ wsId, userId });
253
+ await redis.incr(key);
254
+ await redis.expire(key, AI_TEMP_AUTH_TTL_SECONDS);
255
+ return true;
256
+ } catch {
257
+ return false;
258
+ }
259
+ }
260
+
261
+ export async function decrementAiCreditChargeInFlight({
262
+ wsId,
263
+ userId,
264
+ }: {
265
+ wsId: string;
266
+ userId: string;
267
+ }): Promise<boolean> {
268
+ const redis = await getUpstashRestRedisClient().catch(() => null);
269
+ if (!redis) return false;
270
+
271
+ try {
272
+ const key = creditInFlightKey({ wsId, userId });
273
+ const value = await redis.decr(key);
274
+ if (Number(value) <= 0) {
275
+ await redis.del(key);
276
+ }
277
+ return true;
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ export async function hasAiCreditChargeInFlight({
284
+ wsId,
285
+ userId,
286
+ }: {
287
+ wsId: string;
288
+ userId: string;
289
+ }): Promise<boolean> {
290
+ const redis = await getUpstashRestRedisClient().catch(() => null);
291
+ if (!redis) return false;
292
+
293
+ try {
294
+ const value = await redis.get<number | string>(
295
+ creditInFlightKey({ wsId, userId })
296
+ );
297
+ return Number(value ?? 0) > 0;
298
+ } catch {
299
+ return false;
300
+ }
301
+ }
302
+
303
+ export function isAiCreditSnapshotUsable(
304
+ snapshot: AiCreditSnapshot | null | undefined,
305
+ options: { inFlight?: boolean; now?: number } = {}
306
+ ): snapshot is AiCreditSnapshot {
307
+ if (!snapshot) return false;
308
+ if (options.inFlight) return false;
309
+ if (snapshot.remainingCredits < MINIMUM_SNAPSHOT_REMAINING_CREDITS) {
310
+ return false;
311
+ }
312
+
313
+ const now = options.now ?? Date.now();
314
+ return now - snapshot.updatedAt <= CREDIT_SNAPSHOT_MAX_AGE_MS;
315
+ }