@tuturuuu/utils 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.0](https://github.com/tutur3u/platform/compare/utils-v0.12.1...utils-v0.13.0) (2026-07-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * **tools:** add apps/tools public tools app, remove apps/qr ([204aae8](https://github.com/tutur3u/platform/commit/204aae89a04b4163f2859561f8e832526642e271))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **tasks:** route native personal task moves normally ([5bbba3c](https://github.com/tutur3u/platform/commit/5bbba3c3f193e722cc87cdfe2eb2734f61442cd0))
14
+ * **web:** fail open without redis ([74bfcc7](https://github.com/tutur3u/platform/commit/74bfcc7ea5f188a56f49802c6a48bde0b109e02d))
15
+
16
+ ## [0.12.1](https://github.com/tutur3u/platform/compare/utils-v0.12.0...utils-v0.12.1) (2026-07-03)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **infrastructure:** restore canonical auth host access ([9f6715f](https://github.com/tutur3u/platform/commit/9f6715fb1c5a89598556cba33b16938b76995fc6))
22
+
3
23
  ## [0.12.0](https://github.com/tutur3u/platform/compare/utils-v0.11.0...utils-v0.12.0) (2026-07-02)
4
24
 
5
25
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tuturuuu/utils",
3
3
  "license": "MIT",
4
- "version": "0.12.0",
4
+ "version": "0.13.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -22,9 +22,9 @@
22
22
  "@tanstack/react-query": "^5.101.2",
23
23
  "@tiptap/core": "3.27.1",
24
24
  "@tiptap/react": "3.27.1",
25
- "@tuturuuu/google": "0.0.2",
25
+ "@tuturuuu/google": "0.0.3",
26
26
  "@tuturuuu/icons": "0.0.6",
27
- "@tuturuuu/internal-api": "0.13.0",
27
+ "@tuturuuu/internal-api": "0.14.1",
28
28
  "@tuturuuu/supabase": "0.4.0",
29
29
  "@upstash/ratelimit": "^2.0.8",
30
30
  "@upstash/redis": "^1.38.0",
@@ -45,7 +45,7 @@
45
45
  "zod": "^4.4.3"
46
46
  },
47
47
  "devDependencies": {
48
- "@tuturuuu/types": "0.13.1",
48
+ "@tuturuuu/types": "0.14.0",
49
49
  "@tuturuuu/typescript-config": "0.1.1",
50
50
  "@types/diff": "^8.0.0",
51
51
  "@types/node": "^26.0.1",
@@ -278,6 +278,36 @@ describe('guardApiProxyRequest', () => {
278
278
  expect(mocks.validateEmoji).toHaveBeenCalled();
279
279
  });
280
280
 
281
+ it('skips proxy route buckets when Redis is not configured', async () => {
282
+ vi.stubEnv('NODE_ENV', 'production');
283
+ mocks.extractIp.mockReturnValue('1.2.3.4');
284
+ mocks.isBlocked.mockResolvedValue(null);
285
+ mocks.limit.mockResolvedValue({
286
+ success: false,
287
+ limit: 1,
288
+ remaining: 0,
289
+ reset: Date.now() + 60_000,
290
+ });
291
+ mocks.validateEmoji.mockResolvedValue(null);
292
+
293
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
294
+ await import('../api-proxy-guard.js');
295
+ clearApiProxyGuardLimiterCache();
296
+
297
+ const response = await guardApiProxyRequest(
298
+ makeRequest('/api/test', 'GET'),
299
+ {
300
+ prefixBase: 'proxy:test:api',
301
+ routePolicies: singleReadRoutePolicies(),
302
+ }
303
+ );
304
+
305
+ expect(response).toBeNull();
306
+ expect(mocks.redis).not.toHaveBeenCalled();
307
+ expect(mocks.limit).not.toHaveBeenCalled();
308
+ expect(mocks.validateEmoji).toHaveBeenCalled();
309
+ });
310
+
281
311
  it('allows a blocked IP to submit the rate-limit appeal endpoint only', async () => {
282
312
  vi.stubEnv('NODE_ENV', 'production');
283
313
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -1186,7 +1216,7 @@ describe('guardApiProxyRequest', () => {
1186
1216
  expect(mocks.validateEmoji).not.toHaveBeenCalled();
1187
1217
  });
1188
1218
 
1189
- it('uses local rate limits when the Redis rate limiter is unreachable', async () => {
1219
+ it('skips proxy route buckets when the Redis rate limiter is unreachable', async () => {
1190
1220
  vi.stubEnv('NODE_ENV', 'production');
1191
1221
  vi.stubEnv(
1192
1222
  'UPSTASH_REDIS_REST_URL',
@@ -1196,7 +1226,7 @@ describe('guardApiProxyRequest', () => {
1196
1226
  mocks.redis.mockReturnValue({});
1197
1227
  mocks.extractIp.mockReturnValue('1.2.3.4');
1198
1228
  mocks.isBlocked.mockResolvedValue(null);
1199
- mocks.limit.mockRejectedValueOnce(new TypeError('fetch failed'));
1229
+ mocks.limit.mockRejectedValue(new TypeError('fetch failed'));
1200
1230
  mocks.validateEmoji.mockResolvedValue(null);
1201
1231
 
1202
1232
  const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
@@ -1216,22 +1246,17 @@ describe('guardApiProxyRequest', () => {
1216
1246
  expect(response).toBeNull();
1217
1247
  expect(mocks.limit).toHaveBeenCalledTimes(1);
1218
1248
 
1219
- const blockedResponse = await guardApiProxyRequest(
1249
+ const allowedResponse = await guardApiProxyRequest(
1220
1250
  makeRequest('/api/test', 'GET'),
1221
1251
  options
1222
1252
  );
1223
1253
 
1224
- expect(blockedResponse?.status).toBe(429);
1225
- expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
1226
- 'route-rate-limit'
1227
- );
1228
- expect(blockedResponse?.headers.get('X-RateLimit-Policy')).toBe(
1229
- 'redis-fallback-test'
1230
- );
1231
- expect(mocks.limit).toHaveBeenCalledTimes(1);
1254
+ expect(allowedResponse).toBeNull();
1255
+ expect(mocks.limit).toHaveBeenCalledTimes(2);
1256
+ expect(mocks.validateEmoji).toHaveBeenCalledTimes(2);
1232
1257
  });
1233
1258
 
1234
- it('uses local rate limits when Redis client initialization fails', async () => {
1259
+ it('skips proxy route buckets when Redis client initialization fails', async () => {
1235
1260
  vi.stubEnv('NODE_ENV', 'production');
1236
1261
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1237
1262
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
@@ -1258,16 +1283,14 @@ describe('guardApiProxyRequest', () => {
1258
1283
 
1259
1284
  expect(response).toBeNull();
1260
1285
 
1261
- const blockedResponse = await guardApiProxyRequest(
1286
+ const allowedResponse = await guardApiProxyRequest(
1262
1287
  makeRequest('/api/test', 'GET'),
1263
1288
  options
1264
1289
  );
1265
1290
 
1266
- expect(blockedResponse?.status).toBe(429);
1267
- expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
1268
- 'route-rate-limit'
1269
- );
1291
+ expect(allowedResponse).toBeNull();
1270
1292
  expect(mocks.limit).not.toHaveBeenCalled();
1293
+ expect(mocks.validateEmoji).toHaveBeenCalledTimes(2);
1271
1294
  });
1272
1295
 
1273
1296
  it('rate limits anonymous users/me reads', async () => {
@@ -137,6 +137,29 @@ describe('resolveInternalAppUrl', () => {
137
137
  ).toBeNull();
138
138
  });
139
139
 
140
+ it('recognizes canonical and legacy infrastructure production origins', () => {
141
+ expect(
142
+ getAppDomainByUrl(
143
+ 'https://infrastructure.tuturuuu.com/verify-token?nextUrl=%2Fpersonal'
144
+ )
145
+ ).toMatchObject({
146
+ canonicalUrl:
147
+ 'https://infrastructure.tuturuuu.com/verify-token?nextUrl=%2Fpersonal',
148
+ kind: 'internal',
149
+ name: 'infra',
150
+ });
151
+ expect(
152
+ getAppDomainByUrl(
153
+ 'https://infra.tuturuuu.com/verify-token?nextUrl=%2Fpersonal'
154
+ )
155
+ ).toMatchObject({
156
+ canonicalUrl:
157
+ 'https://infra.tuturuuu.com/verify-token?nextUrl=%2Fpersonal',
158
+ kind: 'internal',
159
+ name: 'infra',
160
+ });
161
+ });
162
+
140
163
  it('canonicalizes production internal app URLs to their registered HTTPS origins', () => {
141
164
  expect(
142
165
  resolveInternalAppUrl({
@@ -152,6 +175,13 @@ describe('resolveInternalAppUrl', () => {
152
175
  fallback: 'http://localhost:7807',
153
176
  })
154
177
  ).toBe('https://meet.tuturuuu.com');
178
+ expect(
179
+ resolveInternalAppUrl({
180
+ appName: 'infra',
181
+ candidates: ['http://infrastructure.tuturuuu.com/'],
182
+ fallback: 'http://localhost:7823',
183
+ })
184
+ ).toBe('https://infrastructure.tuturuuu.com');
155
185
  });
156
186
 
157
187
  it('falls back when every configured URL points at another internal app', () => {
@@ -203,6 +233,7 @@ describe('getLocalInternalAppUrl', () => {
203
233
  ['chat', 'https://chat.tuturuuu.localhost'],
204
234
  ['drive', 'https://drive.tuturuuu.localhost'],
205
235
  ['qr', 'https://qr.tuturuuu.localhost'],
236
+ ['tools', 'https://tools.tuturuuu.localhost'],
206
237
  ['nova', 'https://nova.tuturuuu.localhost'],
207
238
  ['rewise', 'https://rewise.tuturuuu.localhost'],
208
239
  ['tasks', 'https://tasks.tuturuuu.localhost'],
@@ -275,6 +306,7 @@ describe('Portless app origin registry', () => {
275
306
  'https://playground.tuturuuu.localhost',
276
307
  'https://qr.tuturuuu.localhost',
277
308
  'https://rewise.tuturuuu.localhost',
309
+ 'https://tools.tuturuuu.localhost',
278
310
  'https://shortener.tuturuuu.localhost',
279
311
  'https://storefront.tuturuuu.localhost',
280
312
  'https://tanstack.tuturuuu.localhost',
@@ -58,5 +58,26 @@ describe('transformTaskRecord', () => {
58
58
  expect(transformed.projects).toEqual([
59
59
  { id: 'project-1', name: 'Platform', status: 'active' },
60
60
  ]);
61
+ expect(transformed).toMatchObject({
62
+ is_personal_external: false,
63
+ is_personal_external_default: false,
64
+ });
65
+ });
66
+
67
+ it('preserves explicit personal external flags from API responses', () => {
68
+ const transformed = transformTaskRecord({
69
+ id: 'task-1',
70
+ name: 'Task',
71
+ list_id: 'list-1',
72
+ display_number: 1,
73
+ created_at: '2026-01-01T00:00:00.000Z',
74
+ is_personal_external: true,
75
+ is_personal_external_default: true,
76
+ });
77
+
78
+ expect(transformed).toMatchObject({
79
+ is_personal_external: true,
80
+ is_personal_external_default: true,
81
+ });
61
82
  });
62
83
  });
@@ -0,0 +1,93 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ describe('abuse protection Redis fail-open behavior', () => {
4
+ afterEach(() => {
5
+ vi.doUnmock('../../upstash-rest');
6
+ vi.doUnmock('@tuturuuu/supabase/next/server');
7
+ vi.resetModules();
8
+ vi.restoreAllMocks();
9
+ });
10
+
11
+ it('allows checks and skips persistent IP block lookups when Redis is not configured', async () => {
12
+ const from = vi.fn(() => ({
13
+ insert: vi.fn(async () => ({ error: null })),
14
+ }));
15
+ const rpc = vi.fn();
16
+ const createAdminClient = vi.fn(async () => ({ from, rpc }));
17
+ const getRedis = vi.fn();
18
+
19
+ vi.doMock('../../upstash-rest', () => ({
20
+ getUpstashRestRedisClient: getRedis,
21
+ hasUpstashRestEnv: () => false,
22
+ }));
23
+ vi.doMock('@tuturuuu/supabase/next/server', () => ({
24
+ createAdminClient,
25
+ }));
26
+
27
+ const { checkPasswordLoginLimit, isIPBlocked, recordPasswordLoginFailure } =
28
+ await import('../index.js');
29
+
30
+ for (let attempt = 0; attempt < 20; attempt += 1) {
31
+ await recordPasswordLoginFailure('203.0.113.80', 'user@example.com');
32
+ }
33
+
34
+ await expect(isIPBlocked('203.0.113.80')).resolves.toBeNull();
35
+ await expect(
36
+ checkPasswordLoginLimit('203.0.113.80', 'user@example.com')
37
+ ).resolves.toMatchObject({
38
+ allowed: true,
39
+ });
40
+ expect(getRedis).not.toHaveBeenCalled();
41
+ expect(rpc).not.toHaveBeenCalledWith('get_active_ip_block', {
42
+ p_ip_address: '203.0.113.80',
43
+ });
44
+ expect(from).not.toHaveBeenCalledWith('blocked_ips');
45
+ });
46
+
47
+ it('allows checks and skips counter writes after Redis commands fail', async () => {
48
+ const from = vi.fn(() => ({
49
+ insert: vi.fn(async () => ({ error: null })),
50
+ }));
51
+ const rpc = vi.fn();
52
+ const createAdminClient = vi.fn(async () => ({ from, rpc }));
53
+ const redis = {
54
+ del: vi.fn(),
55
+ expire: vi.fn(),
56
+ get: vi.fn(async () => {
57
+ throw new Error('redis unavailable');
58
+ }),
59
+ incr: vi.fn(async () => {
60
+ throw new Error('redis unavailable');
61
+ }),
62
+ set: vi.fn(),
63
+ ttl: vi.fn(),
64
+ };
65
+
66
+ vi.doMock('../../upstash-rest', () => ({
67
+ getUpstashRestRedisClient: async () => redis,
68
+ hasUpstashRestEnv: () => true,
69
+ }));
70
+ vi.doMock('@tuturuuu/supabase/next/server', () => ({
71
+ createAdminClient,
72
+ }));
73
+
74
+ const { checkPasswordLoginLimit, isIPBlocked, recordPasswordLoginFailure } =
75
+ await import('../index.js');
76
+
77
+ await expect(isIPBlocked('203.0.113.81')).resolves.toBeNull();
78
+ await expect(
79
+ checkPasswordLoginLimit('203.0.113.81', 'user@example.com')
80
+ ).resolves.toMatchObject({
81
+ allowed: true,
82
+ });
83
+ await expect(
84
+ recordPasswordLoginFailure('203.0.113.81', 'user@example.com')
85
+ ).resolves.toBeUndefined();
86
+ expect(redis.expire).not.toHaveBeenCalled();
87
+ expect(redis.set).not.toHaveBeenCalled();
88
+ expect(rpc).not.toHaveBeenCalledWith('get_active_ip_block', {
89
+ p_ip_address: '203.0.113.81',
90
+ });
91
+ expect(from).not.toHaveBeenCalledWith('blocked_ips');
92
+ });
93
+ });
@@ -2,7 +2,76 @@
2
2
  * Unit tests for OTP Abuse Protection System
3
3
  */
4
4
 
5
- import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ const mocks = vi.hoisted(() => {
8
+ const store = new Map<string, { expiresAt: number | null; value: unknown }>();
9
+
10
+ function getEntry(key: string) {
11
+ const entry = store.get(key);
12
+ if (!entry) return null;
13
+
14
+ if (entry.expiresAt != null && entry.expiresAt <= Date.now()) {
15
+ store.delete(key);
16
+ return null;
17
+ }
18
+
19
+ return entry;
20
+ }
21
+
22
+ return {
23
+ getRedis: vi.fn(),
24
+ redis: {
25
+ del: vi.fn(async (...keys: string[]) => {
26
+ let deleted = 0;
27
+ for (const key of keys) {
28
+ if (store.delete(key)) deleted += 1;
29
+ }
30
+ return deleted;
31
+ }),
32
+ expire: vi.fn(async (key: string, seconds: number) => {
33
+ const entry = getEntry(key);
34
+ if (!entry) return 0;
35
+ entry.expiresAt = Date.now() + seconds * 1000;
36
+ return 1;
37
+ }),
38
+ get: vi.fn(async <T = unknown>(key: string): Promise<T | null> => {
39
+ return (getEntry(key)?.value as T | undefined) ?? null;
40
+ }),
41
+ incr: vi.fn(async (key: string) => {
42
+ const entry = getEntry(key);
43
+ const nextValue =
44
+ typeof entry?.value === 'number' ? entry.value + 1 : 1;
45
+ store.set(key, {
46
+ expiresAt: entry?.expiresAt ?? null,
47
+ value: nextValue,
48
+ });
49
+ return nextValue;
50
+ }),
51
+ set: vi.fn(
52
+ async (key: string, value: unknown, options?: { ex?: number }) => {
53
+ store.set(key, {
54
+ expiresAt: options?.ex ? Date.now() + options.ex * 1000 : null,
55
+ value,
56
+ });
57
+ return 'OK';
58
+ }
59
+ ),
60
+ ttl: vi.fn(async (key: string) => {
61
+ const entry = getEntry(key);
62
+ if (!entry) return -2;
63
+ if (entry.expiresAt == null) return -1;
64
+ return Math.ceil((entry.expiresAt - Date.now()) / 1000);
65
+ }),
66
+ },
67
+ store,
68
+ };
69
+ });
70
+
71
+ vi.mock('../../upstash-rest', () => ({
72
+ getUpstashRestRedisClient: () => mocks.getRedis(),
73
+ hasUpstashRestEnv: () => true,
74
+ }));
6
75
 
7
76
  import {
8
77
  ABUSE_THRESHOLDS,
@@ -29,6 +98,12 @@ import {
29
98
  } from '../index';
30
99
 
31
100
  describe('abuse-protection', () => {
101
+ beforeEach(() => {
102
+ vi.clearAllMocks();
103
+ mocks.store.clear();
104
+ mocks.getRedis.mockResolvedValue(mocks.redis);
105
+ });
106
+
32
107
  afterEach(() => {
33
108
  vi.useRealTimers();
34
109
  vi.unstubAllEnvs();
@@ -31,9 +31,6 @@ export * from './reputation';
31
31
  export * from './types';
32
32
  export * from './user-agent';
33
33
 
34
- // In-memory fallback store
35
- const memoryStore = new Map<string, { count: number; expiresAt: number }>();
36
-
37
34
  const SHARED_IP_THROTTLE_ONLY_BLOCK_REASONS = new Set<AbuseEventType>([
38
35
  'otp_send',
39
36
  'otp_verify_failed',
@@ -50,6 +47,28 @@ export function isSharedIpThrottleOnlyBlockReason(reason: AbuseEventType) {
50
47
  let redisClient: RedisClient | null = null;
51
48
  let redisInitialized = false;
52
49
 
50
+ function markRedisUnavailable(message: string, ...details: unknown[]): void {
51
+ if (details.length > 0) {
52
+ console.warn(message, ...details);
53
+ } else {
54
+ console.warn(message);
55
+ }
56
+
57
+ redisClient = null;
58
+ redisInitialized = true;
59
+ }
60
+
61
+ function markRedisCommandFailed(message: string, ...details: unknown[]): void {
62
+ if (details.length > 0) {
63
+ console.warn(message, ...details);
64
+ } else {
65
+ console.warn(message);
66
+ }
67
+
68
+ redisClient = null;
69
+ redisInitialized = false;
70
+ }
71
+
53
72
  function parsePositiveIntEnv(name: string, fallback: number): number {
54
73
  const rawValue = process.env[name];
55
74
  if (!rawValue) {
@@ -85,19 +104,22 @@ async function getRedisClient(): Promise<RedisClient | null> {
85
104
 
86
105
  try {
87
106
  if (!hasUpstashRestEnv()) {
88
- console.warn(
89
- '[Abuse Protection] Redis not configured - falling back to memory'
107
+ markRedisUnavailable(
108
+ '[Abuse Protection] Redis not configured - failing open'
90
109
  );
91
- redisInitialized = true;
92
110
  return null;
93
111
  }
94
112
 
95
113
  redisClient = await getUpstashRestRedisClient();
114
+ if (!redisClient) {
115
+ markRedisUnavailable(
116
+ '[Abuse Protection] Redis client unavailable - failing open'
117
+ );
118
+ }
96
119
  redisInitialized = true;
97
120
  return redisClient;
98
121
  } catch (error) {
99
- console.warn('[Abuse Protection] Redis unavailable:', error);
100
- redisInitialized = true;
122
+ markRedisUnavailable('[Abuse Protection] Redis unavailable:', error);
101
123
  return null;
102
124
  }
103
125
  }
@@ -191,83 +213,70 @@ function createAbuseLogContext(
191
213
  }
192
214
 
193
215
  /**
194
- * Increment a counter in Redis or memory with automatic expiration
216
+ * Increment a Redis counter with automatic expiration.
217
+ * Fails open when Redis is unavailable so abuse protection never becomes an
218
+ * app availability dependency.
195
219
  */
196
220
  async function incrementCounter(
197
221
  key: string,
198
222
  windowMs: number
199
223
  ): Promise<{ count: number; ttl: number }> {
200
224
  const redis = await getRedisClient();
225
+ const neutral = { count: 0, ttl: Math.ceil(windowMs / 1000) };
201
226
 
202
- if (redis) {
203
- try {
204
- const count = await redis.incr(key);
205
- if (count === 1) {
206
- await redis.expire(key, Math.ceil(windowMs / 1000));
207
- }
208
- const ttl = await redis.ttl(key);
209
- return { count, ttl: ttl > 0 ? ttl : Math.ceil(windowMs / 1000) };
210
- } catch (error) {
211
- console.error('[Abuse Protection] Redis error:', error);
212
- // Fall through to memory
213
- }
227
+ if (!redis) {
228
+ return neutral;
214
229
  }
215
230
 
216
- // Memory fallback
217
- const now = Date.now();
218
- const existing = memoryStore.get(key);
219
-
220
- if (!existing || now > existing.expiresAt) {
221
- memoryStore.set(key, { count: 1, expiresAt: now + windowMs });
222
- return { count: 1, ttl: Math.ceil(windowMs / 1000) };
231
+ try {
232
+ const count = await redis.incr(key);
233
+ if (count === 1) {
234
+ await redis.expire(key, Math.ceil(windowMs / 1000));
235
+ }
236
+ const ttl = await redis.ttl(key);
237
+ return { count, ttl: ttl > 0 ? ttl : Math.ceil(windowMs / 1000) };
238
+ } catch (error) {
239
+ markRedisCommandFailed('[Abuse Protection] Redis counter error:', error);
240
+ return neutral;
223
241
  }
224
-
225
- existing.count++;
226
- return {
227
- count: existing.count,
228
- ttl: Math.ceil((existing.expiresAt - now) / 1000),
229
- };
230
242
  }
231
243
 
232
244
  /**
233
- * Get counter value from Redis or memory
245
+ * Get a counter value from Redis.
234
246
  */
235
247
  async function getCounter(key: string): Promise<number> {
236
248
  const redis = await getRedisClient();
237
249
 
238
- if (redis) {
239
- try {
240
- const count = await redis.get<number>(key);
241
- return count || 0;
242
- } catch {
243
- // Fall through to memory
244
- }
250
+ if (!redis) {
251
+ return 0;
245
252
  }
246
253
 
247
- const existing = memoryStore.get(key);
248
- if (!existing || Date.now() > existing.expiresAt) {
254
+ try {
255
+ const count = await redis.get<number>(key);
256
+ return count || 0;
257
+ } catch (error) {
258
+ markRedisCommandFailed(
259
+ '[Abuse Protection] Redis counter read error:',
260
+ error
261
+ );
249
262
  return 0;
250
263
  }
251
- return existing.count;
252
264
  }
253
265
 
254
266
  /**
255
- * Delete keys from Redis or memory
267
+ * Delete Redis keys when Redis is available.
256
268
  */
257
269
  async function deleteKeys(...keys: string[]): Promise<void> {
258
270
  const redis = await getRedisClient();
259
271
 
260
- if (redis) {
261
- try {
262
- await redis.del(...keys);
263
- return;
264
- } catch {
265
- // Fall through to memory
266
- }
272
+ if (!redis) {
273
+ return;
267
274
  }
268
275
 
269
- for (const key of keys) {
270
- memoryStore.delete(key);
276
+ try {
277
+ await redis.del(...keys);
278
+ } catch (error) {
279
+ markRedisCommandFailed('[Abuse Protection] Redis delete error:', error);
271
280
  }
272
281
  }
273
282
 
@@ -278,23 +287,17 @@ async function deleteKeysWithCount(...keys: string[]): Promise<number> {
278
287
 
279
288
  const redis = await getRedisClient();
280
289
 
281
- if (redis) {
282
- try {
283
- const deleted = await redis.del(...keys);
284
- return typeof deleted === 'number' ? deleted : 0;
285
- } catch {
286
- // Fall through to memory
287
- }
290
+ if (!redis) {
291
+ return 0;
288
292
  }
289
293
 
290
- let deleted = 0;
291
- for (const key of keys) {
292
- if (memoryStore.delete(key)) {
293
- deleted++;
294
- }
294
+ try {
295
+ const deleted = await redis.del(...keys);
296
+ return typeof deleted === 'number' ? deleted : 0;
297
+ } catch (error) {
298
+ markRedisCommandFailed('[Abuse Protection] Redis delete error:', error);
299
+ return 0;
295
300
  }
296
-
297
- return deleted;
298
301
  }
299
302
 
300
303
  /**
@@ -324,30 +327,33 @@ export async function isIPBlocked(
324
327
  ): Promise<BlockInfo | null> {
325
328
  const redis = await getRedisClient();
326
329
 
330
+ if (!redis) {
331
+ return null;
332
+ }
333
+
327
334
  // Check Redis cache first
328
- if (redis) {
329
- try {
330
- const cached = await redis.get<string>(REDIS_KEYS.IP_BLOCKED(ipAddress));
331
- if (cached) {
332
- const blockInfo =
333
- typeof cached === 'string' ? JSON.parse(cached) : cached;
334
- if (new Date(blockInfo.expiresAt) > new Date()) {
335
- return {
336
- id: blockInfo.id,
337
- blockLevel: blockInfo.level,
338
- reason: blockInfo.reason,
339
- expiresAt: new Date(blockInfo.expiresAt),
340
- blockedAt: new Date(blockInfo.blockedAt),
341
- };
342
- }
335
+ try {
336
+ const cached = await redis.get<string>(REDIS_KEYS.IP_BLOCKED(ipAddress));
337
+ if (cached) {
338
+ const blockInfo =
339
+ typeof cached === 'string' ? JSON.parse(cached) : cached;
340
+ if (new Date(blockInfo.expiresAt) > new Date()) {
341
+ return {
342
+ id: blockInfo.id,
343
+ blockLevel: blockInfo.level,
344
+ reason: blockInfo.reason,
345
+ expiresAt: new Date(blockInfo.expiresAt),
346
+ blockedAt: new Date(blockInfo.blockedAt),
347
+ };
343
348
  }
344
- } catch (error) {
345
- console.error(
346
- '[Abuse Protection] Redis cache error:',
347
- error,
348
- createAbuseLogContext(ipAddress, context)
349
- );
350
349
  }
350
+ } catch (error) {
351
+ markRedisCommandFailed(
352
+ '[Abuse Protection] Redis cache error:',
353
+ error,
354
+ createAbuseLogContext(ipAddress, context)
355
+ );
356
+ return null;
351
357
  }
352
358
 
353
359
  // Check database
@@ -376,21 +382,29 @@ export async function isIPBlocked(
376
382
  };
377
383
 
378
384
  // Cache in Redis
379
- if (redis && blockInfo.expiresAt > new Date()) {
385
+ if (blockInfo.expiresAt > new Date()) {
380
386
  const ttl = Math.ceil(
381
387
  (blockInfo.expiresAt.getTime() - Date.now()) / 1000
382
388
  );
383
- await redis.set(
384
- REDIS_KEYS.IP_BLOCKED(ipAddress),
385
- JSON.stringify({
386
- id: blockInfo.id,
387
- level: blockInfo.blockLevel,
388
- reason: blockInfo.reason,
389
- expiresAt: blockInfo.expiresAt.toISOString(),
390
- blockedAt: blockInfo.blockedAt.toISOString(),
391
- }),
392
- { ex: ttl }
393
- );
389
+ try {
390
+ await redis.set(
391
+ REDIS_KEYS.IP_BLOCKED(ipAddress),
392
+ JSON.stringify({
393
+ id: blockInfo.id,
394
+ level: blockInfo.blockLevel,
395
+ reason: blockInfo.reason,
396
+ expiresAt: blockInfo.expiresAt.toISOString(),
397
+ blockedAt: blockInfo.blockedAt.toISOString(),
398
+ }),
399
+ { ex: ttl }
400
+ );
401
+ } catch (error) {
402
+ markRedisCommandFailed(
403
+ '[Abuse Protection] Redis cache set error:',
404
+ error
405
+ );
406
+ return null;
407
+ }
394
408
  }
395
409
 
396
410
  return blockInfo;
@@ -413,31 +427,25 @@ export async function blockIP(
413
427
  }
414
428
 
415
429
  try {
430
+ const redis = await getRedisClient();
431
+ if (!redis) return;
432
+
416
433
  const sbAdmin = await getSupabaseAdmin();
417
434
  if (!sbAdmin) return;
418
435
 
419
- const redis = await getRedisClient();
420
-
421
436
  // Get current block level
422
437
  let currentLevel = 0;
423
- if (redis) {
424
- try {
425
- const level = await redis.get<number>(
426
- REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress)
427
- );
428
- currentLevel = level || 0;
429
- } catch {
430
- // Check DB fallback
431
- const { data } = await sbAdmin.rpc('get_ip_block_level', {
432
- p_ip_address: ipAddress,
433
- });
434
- currentLevel = (data as number) || 0;
435
- }
436
- } else {
437
- const { data } = await sbAdmin.rpc('get_ip_block_level', {
438
- p_ip_address: ipAddress,
439
- });
440
- currentLevel = (data as number) || 0;
438
+ try {
439
+ const level = await redis.get<number>(
440
+ REDIS_KEYS.IP_BLOCK_LEVEL(ipAddress)
441
+ );
442
+ currentLevel = level || 0;
443
+ } catch (error) {
444
+ markRedisCommandFailed(
445
+ '[Abuse Protection] Redis block-level error:',
446
+ error
447
+ );
448
+ return;
441
449
  }
442
450
 
443
451
  // Calculate new block level (max 4)
@@ -470,7 +478,7 @@ export async function blockIP(
470
478
  }
471
479
 
472
480
  // Update Redis cache
473
- if (redis) {
481
+ try {
474
482
  await Promise.all([
475
483
  redis.set(
476
484
  REDIS_KEYS.IP_BLOCKED(ipAddress),
@@ -487,6 +495,11 @@ export async function blockIP(
487
495
  ex: WINDOW_MS.TWENTY_FOUR_HOURS / 1000,
488
496
  }),
489
497
  ]);
498
+ } catch (error) {
499
+ markRedisCommandFailed(
500
+ '[Abuse Protection] Redis block cache error:',
501
+ error
502
+ );
490
503
  }
491
504
 
492
505
  console.log(
@@ -112,13 +112,7 @@ type RateLimiter = {
112
112
  limit: (identifier: string) => Promise<RateLimitResult>;
113
113
  };
114
114
 
115
- type LocalRateLimitState = {
116
- count: number;
117
- reset: number;
118
- };
119
-
120
115
  const limiterCache = new Map<string, Limiters>();
121
- const localLimiterState = new Map<string, LocalRateLimitState>();
122
116
  const GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN =
123
117
  /^sb-[a-z0-9-]+-auth-token(?:\.\d+)?$/i;
124
118
  const UUID_PATH_SEGMENT =
@@ -697,87 +691,10 @@ function createRedisRateLimitBuckets(
697
691
  }));
698
692
  }
699
693
 
700
- function rateLimitDurationMs(duration: RateLimitConfig['duration']) {
701
- switch (duration) {
702
- case '1 m':
703
- return 60_000;
704
- case '1 h':
705
- return 60 * 60_000;
706
- case '1 d':
707
- return 24 * 60 * 60_000;
708
- }
709
- }
710
-
711
- function createLocalRateLimiter(
712
- prefix: string,
713
- config: RateLimitConfig
714
- ): RateLimiter {
715
- const durationMs = rateLimitDurationMs(config.duration);
716
-
717
- return {
718
- async limit(identifier: string) {
719
- const now = Date.now();
720
- const key = `${prefix}:${identifier}`;
721
- let state = localLimiterState.get(key);
722
-
723
- if (!state || state.reset <= now) {
724
- state = {
725
- count: 0,
726
- reset: now + durationMs,
727
- };
728
- }
729
-
730
- if (state.count >= config.limit) {
731
- localLimiterState.set(key, state);
732
- return {
733
- limit: config.limit,
734
- remaining: 0,
735
- reset: state.reset,
736
- success: false,
737
- };
738
- }
739
-
740
- state.count += 1;
741
- localLimiterState.set(key, state);
742
-
743
- return {
744
- limit: config.limit,
745
- remaining: Math.max(0, config.limit - state.count),
746
- reset: state.reset,
747
- success: true,
748
- };
749
- },
750
- };
751
- }
752
-
753
- function createLocalRateLimitBuckets(
754
- prefixBase: string,
755
- kind: 'get' | 'mutate',
756
- configs: RateLimitConfig[]
757
- ): RateLimitBucket[] {
758
- return configs.map((config) => ({
759
- window: config.window,
760
- limiter: createLocalRateLimiter(
761
- `${prefixBase}:local:${kind}:${config.window}`,
762
- config
763
- ),
764
- }));
765
- }
766
-
767
- function createLocalRateLimiters(
768
- prefixBase: string,
769
- profile: RateLimitProfile
770
- ): Limiters {
771
- return {
772
- get: createLocalRateLimitBuckets(prefixBase, 'get', profile.get),
773
- mutate: createLocalRateLimitBuckets(prefixBase, 'mutate', profile.mutate),
774
- };
775
- }
776
-
777
694
  async function getRateLimiters(
778
695
  prefixBase: string,
779
696
  profile: RateLimitProfile
780
- ): Promise<Limiters> {
697
+ ): Promise<Limiters | null> {
781
698
  const cacheKey = `${prefixBase}:${JSON.stringify(profile)}`;
782
699
  const cached = limiterCache.get(cacheKey);
783
700
  if (cached) {
@@ -786,9 +703,7 @@ async function getRateLimiters(
786
703
 
787
704
  const redis = await getUpstashRatelimitRedisClient().catch(() => null);
788
705
  if (!redis) {
789
- const localLimiters = createLocalRateLimiters(prefixBase, profile);
790
- limiterCache.set(cacheKey, localLimiters);
791
- return localLimiters;
706
+ return null;
792
707
  }
793
708
 
794
709
  const limiters = {
@@ -805,16 +720,6 @@ async function getRateLimiters(
805
720
  return limiters;
806
721
  }
807
722
 
808
- function replaceWithLocalRateLimiters(
809
- cacheKey: string,
810
- prefixBase: string,
811
- profile: RateLimitProfile
812
- ): Limiters {
813
- const localLimiters = createLocalRateLimiters(prefixBase, profile);
814
- limiterCache.set(cacheKey, localLimiters);
815
- return localLimiters;
816
- }
817
-
818
723
  function getRoutePolicy(
819
724
  req: NextRequest,
820
725
  routePolicies: ProxyRoutePolicy[]
@@ -1242,7 +1147,6 @@ export function isTrustedProxyBypassRequest(
1242
1147
 
1243
1148
  export function clearApiProxyGuardLimiterCache() {
1244
1149
  limiterCache.clear();
1245
- localLimiterState.clear();
1246
1150
  }
1247
1151
 
1248
1152
  async function requestBodyExceedsLimit(
@@ -1418,32 +1322,26 @@ export async function guardApiProxyRequest(
1418
1322
  );
1419
1323
  const limiterCacheKey = `${limiterPrefix}:${JSON.stringify(rateLimits)}`;
1420
1324
  const limiters = await getRateLimiters(limiterPrefix, rateLimits);
1421
- let activeLimiters = isRead ? limiters.get : limiters.mutate;
1325
+ const activeLimiters = limiters
1326
+ ? isRead
1327
+ ? limiters.get
1328
+ : limiters.mutate
1329
+ : [];
1422
1330
 
1423
1331
  for (let index = 0; index < activeLimiters.length; index += 1) {
1424
1332
  const { limiter, window } = activeLimiters[index]!;
1425
- let result: RateLimitResult;
1333
+ let result: RateLimitResult | null | undefined;
1426
1334
 
1427
1335
  try {
1428
1336
  result = await limiter.limit(limiterId);
1429
1337
  } catch {
1430
- const fallbackLimiters = replaceWithLocalRateLimiters(
1431
- limiterCacheKey,
1432
- limiterPrefix,
1433
- rateLimits
1434
- );
1435
- activeLimiters = isRead
1436
- ? fallbackLimiters.get
1437
- : fallbackLimiters.mutate;
1438
- const fallbackBucket =
1439
- activeLimiters[index] ??
1440
- activeLimiters.find((bucket) => bucket.window === window);
1441
-
1442
- if (!fallbackBucket) {
1443
- continue;
1444
- }
1445
-
1446
- result = await fallbackBucket.limiter.limit(limiterId);
1338
+ limiterCache.delete(limiterCacheKey);
1339
+ break;
1340
+ }
1341
+
1342
+ if (!result) {
1343
+ limiterCache.delete(limiterCacheKey);
1344
+ break;
1447
1345
  }
1448
1346
 
1449
1347
  const { success, limit, remaining, reset } = result;
@@ -40,6 +40,10 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
40
40
  name: 'qr',
41
41
  url: 'https://qr.tuturuuu.com',
42
42
  },
43
+ {
44
+ name: 'tools',
45
+ url: 'https://tools.tuturuuu.com',
46
+ },
43
47
  {
44
48
  name: 'nova',
45
49
  url: 'https://nova.ai.vn',
@@ -60,6 +64,10 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
60
64
  name: 'finance',
61
65
  url: 'https://finance.tuturuuu.com',
62
66
  },
67
+ {
68
+ name: 'infra',
69
+ url: 'https://infrastructure.tuturuuu.com',
70
+ },
63
71
  {
64
72
  name: 'infra',
65
73
  url: 'https://infra.tuturuuu.com',
@@ -135,6 +143,10 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
135
143
  name: 'qr',
136
144
  url: getTuturuuuPortlessAppOrigin('qr'),
137
145
  },
146
+ {
147
+ name: 'tools',
148
+ url: getTuturuuuPortlessAppOrigin('tools'),
149
+ },
138
150
  {
139
151
  name: 'nova',
140
152
  url: getTuturuuuPortlessAppOrigin('nova'),
@@ -222,6 +234,10 @@ export const LOCALHOST_INTERNAL_APP_DOMAINS = [
222
234
  name: 'qr',
223
235
  url: 'http://localhost:7819',
224
236
  },
237
+ {
238
+ name: 'tools',
239
+ url: 'http://localhost:7825',
240
+ },
225
241
  {
226
242
  name: 'platform',
227
243
  url: 'http://localhost:7803',
@@ -263,16 +263,16 @@ export const LAUNCHABLE_APPS = [
263
263
  title: 'Playground',
264
264
  },
265
265
  {
266
- aliases: ['QR Code', 'Generator'],
267
- appRoot: 'apps/qr',
266
+ aliases: ['QR Code', 'Generator', 'Password', 'Random', 'UUID', 'Token'],
267
+ appRoot: 'apps/tools',
268
268
  category: 'developer',
269
269
  defaultPath: '/',
270
- localhostOrigin: 'http://localhost:7819',
271
- packageName: '@tuturuuu/qr',
272
- portlessApp: 'qr',
273
- productionUrl: 'https://qr.tuturuuu.com',
274
- slug: 'qr',
275
- title: 'QR',
270
+ localhostOrigin: 'http://localhost:7825',
271
+ packageName: '@tuturuuu/tools',
272
+ portlessApp: 'tools',
273
+ productionUrl: 'https://tools.tuturuuu.com',
274
+ slug: 'tools',
275
+ title: 'Tools',
276
276
  },
277
277
  {
278
278
  aliases: ['Review', 'Flashcards'],
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  describe('platform release metadata', () => {
9
9
  it('uses the centralized shared browser app version', () => {
10
- expect(TUTURUUU_PLATFORM_VERSION).toBe('0.16.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.18.0'); // x-release-please-version
11
11
  });
12
12
 
13
13
  it('normalizes generated metadata and derives a short hash', () => {
@@ -1,6 +1,6 @@
1
1
  import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
2
2
 
3
- export const TUTURUUU_PLATFORM_VERSION = '0.16.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.18.0'; // x-release-please-version
4
4
 
5
5
  export type PlatformBuildMetadataInput = {
6
6
  builtAt?: string | null;
package/src/portless.ts CHANGED
@@ -26,6 +26,7 @@ export const TUTURUUU_PORTLESS_APP_ORIGINS = {
26
26
  playground: `https://playground.${TUTURUUU_PORTLESS_ROOT_HOST}`,
27
27
  qr: `https://qr.${TUTURUUU_PORTLESS_ROOT_HOST}`,
28
28
  rewise: `https://rewise.${TUTURUUU_PORTLESS_ROOT_HOST}`,
29
+ tools: `https://tools.${TUTURUUU_PORTLESS_ROOT_HOST}`,
29
30
  shortener: `https://shortener.${TUTURUUU_PORTLESS_ROOT_HOST}`,
30
31
  storefront: `https://storefront.${TUTURUUU_PORTLESS_ROOT_HOST}`,
31
32
  'tanstack-web': `https://tanstack.${TUTURUUU_PORTLESS_ROOT_HOST}`,
@@ -142,6 +142,8 @@ export function transformTaskRecord(task: any): Task {
142
142
 
143
143
  return {
144
144
  ...task,
145
+ is_personal_external: task.is_personal_external ?? false,
146
+ is_personal_external_default: task.is_personal_external_default ?? false,
145
147
  assignees: transformAssignees(task.assignees),
146
148
  labels: normalizedLabels,
147
149
  projects: normalizedProjects,