@tuturuuu/utils 0.33.1 → 0.33.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/package.json +4 -4
- package/src/__tests__/api-proxy-bearer-trust.test.ts +193 -0
- package/src/__tests__/calendar-event-colors.test.ts +103 -0
- package/src/api-proxy-guard.ts +9 -17
- package/src/calendar-event-colors.ts +62 -0
- package/src/common/metadata.test.ts +13 -1
- package/src/email/client.ts +1 -5
- package/src/email/reviewer-domain.d.mts +1 -0
- package/src/email/reviewer-domain.mjs +6 -0
- package/src/google-calendar-colors.test.ts +87 -0
- package/src/google-calendar-colors.ts +112 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.33.3](https://github.com/tutur3u/platform/compare/utils-v0.33.2...utils-v0.33.3) (2026-10-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **calendar:** preserve opaque rendering cues and hydrate source colors ([33847dd](https://github.com/tutur3u/platform/commit/33847ddcd8a3cf2b5cef8ebf50ca4c1700424a27))
|
|
9
|
+
* **calendar:** render opaque readable event colors ([#5694](https://github.com/tutur3u/platform/issues/5694)) ([6e3d81d](https://github.com/tutur3u/platform/commit/6e3d81d6fdd6f8155f12e36ede58d31fde0ac362))
|
|
10
|
+
* **mobile:** harden timezone retry recovery ([264b4b8](https://github.com/tutur3u/platform/commit/264b4b89ab9e1cb4194374a3714b46832c5e486a))
|
|
11
|
+
* **mobile:** resolve timezone settings with verified native sessions ([#5720](https://github.com/tutur3u/platform/issues/5720)) ([5640ca2](https://github.com/tutur3u/platform/commit/5640ca22bcbfda6cd0470f39171ffa2366af779e))
|
|
12
|
+
* **mobile:** restore verified timezone resolution ([462c68b](https://github.com/tutur3u/platform/commit/462c68bf22b2b412dd1825c12c3558b10b028a40))
|
|
13
|
+
* **mobile:** retain timezone snapshots during overlapping loads ([eff42e2](https://github.com/tutur3u/platform/commit/eff42e2cf9d3159ee6fc68be1079df5f50fd92e2))
|
|
14
|
+
|
|
15
|
+
## [0.33.2](https://github.com/tutur3u/platform/compare/utils-v0.33.1...utils-v0.33.2) (2026-10-01)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **calendar:** preserve Google color identity and inheritance ([#5657](https://github.com/tutur3u/platform/issues/5657)) ([0c2aac6](https://github.com/tutur3u/platform/commit/0c2aac6a1a2b6e9f6eb66bde1e783bf407603cff))
|
|
21
|
+
* **calendar:** preserve Google provider color identity and inheritance ([08f2363](https://github.com/tutur3u/platform/commit/08f236349d1dc29ad0593de8f668d5ce504f38d0))
|
|
22
|
+
|
|
3
23
|
## [0.33.1](https://github.com/tutur3u/platform/compare/utils-v0.33.0...utils-v0.33.1) (2026-09-29)
|
|
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.33.
|
|
4
|
+
"version": "0.33.3",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@tiptap/react": "3.31.3",
|
|
25
25
|
"@tuturuuu/google": "0.1.1",
|
|
26
26
|
"@tuturuuu/icons": "0.1.1",
|
|
27
|
-
"@tuturuuu/internal-api": "0.
|
|
27
|
+
"@tuturuuu/internal-api": "0.48.0",
|
|
28
28
|
"@tuturuuu/supabase": "0.5.2",
|
|
29
29
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
30
|
"@upstash/redis": "^1.38.4",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"server-only": "^0.0.1",
|
|
42
42
|
"uuid": "^14.0.2",
|
|
43
43
|
"y-prosemirror": "^1.3.7",
|
|
44
|
-
"yjs": "^13.6.
|
|
44
|
+
"yjs": "^13.6.33",
|
|
45
45
|
"zod": "^4.6.2"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@tuturuuu/types": "0.33.
|
|
48
|
+
"@tuturuuu/types": "0.33.1",
|
|
49
49
|
"@tuturuuu/typescript-config": "0.1.2",
|
|
50
50
|
"@types/diff": "^8.0.0",
|
|
51
51
|
"@types/node": "^26.5.1",
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
limit: vi.fn(),
|
|
6
|
+
trust: vi.fn(),
|
|
7
|
+
blocked: vi.fn(),
|
|
8
|
+
prefixes: [] as string[],
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('../required-mfa-runtime', () => ({
|
|
11
|
+
enforceRequiredMfaRequest: async () => null,
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('@tuturuuu/supabase/next/server', () => ({
|
|
14
|
+
createAdminClient: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('@upstash/ratelimit', () => ({
|
|
17
|
+
Ratelimit: class {
|
|
18
|
+
static slidingWindow() {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
constructor(config: { prefix: string }) {
|
|
22
|
+
mocks.prefixes.push(config.prefix);
|
|
23
|
+
}
|
|
24
|
+
limit(key: string) {
|
|
25
|
+
return mocks.limit(key);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('@upstash/redis', () => ({
|
|
30
|
+
Redis: class {
|
|
31
|
+
static fromEnv() {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
vi.mock('../abuse-protection/edge', () => ({
|
|
37
|
+
extractIPFromRequest: () => '192.0.2.1',
|
|
38
|
+
isIPBlockedEdge: () => mocks.blocked(),
|
|
39
|
+
readEdgeAbuseProtectionControls: async () => ({
|
|
40
|
+
ipBlockingEnabled: true,
|
|
41
|
+
rateLimitsEnabled: true,
|
|
42
|
+
}),
|
|
43
|
+
}));
|
|
44
|
+
vi.mock('../abuse-protection/edge-trust', () => ({
|
|
45
|
+
getCachedTrustEntries: (keys: string[]) => mocks.trust(keys),
|
|
46
|
+
hasCachedIpBlockAppealRelief: async () => false,
|
|
47
|
+
}));
|
|
48
|
+
vi.mock('../request-emoji-limit', () => ({
|
|
49
|
+
validateRequestEmojiLimit: async () => null,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// Use the actual server subject builder, rather than a separately invented hash.
|
|
53
|
+
import { buildAbuseRiskSubjects } from '../abuse-protection/reputation';
|
|
54
|
+
|
|
55
|
+
const path = '/api/v1/mobile-calendar/api/v1/users/calendar-settings';
|
|
56
|
+
const token = 'synthetic-native-session';
|
|
57
|
+
function verifiedKey(value = token) {
|
|
58
|
+
return buildAbuseRiskSubjects({
|
|
59
|
+
headers: { authorization: `Bearer ${value}` },
|
|
60
|
+
userId: 'synthetic-user',
|
|
61
|
+
}).find((subject) => subject.subject_type === 'session')!.subject_key;
|
|
62
|
+
}
|
|
63
|
+
async function request(authorization = `Bearer ${token}`, cookie?: string) {
|
|
64
|
+
const { guardApiProxyRequest } = await import('../api-proxy-guard.js');
|
|
65
|
+
return guardApiProxyRequest(
|
|
66
|
+
new NextRequest(`https://infra.test${path}`, {
|
|
67
|
+
headers: { authorization, ...(cookie ? { cookie } : {}) },
|
|
68
|
+
}),
|
|
69
|
+
{ prefixBase: 'proxy:infra:api' }
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('native Calendar gateway verified session parity', () => {
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
vi.resetModules();
|
|
76
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
77
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
78
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'synthetic');
|
|
79
|
+
vi.stubEnv('API_PROXY_EDGE_TRUST_ENABLED', '1');
|
|
80
|
+
mocks.prefixes.length = 0;
|
|
81
|
+
mocks.limit.mockReset().mockResolvedValue({
|
|
82
|
+
success: false,
|
|
83
|
+
limit: 60,
|
|
84
|
+
remaining: 0,
|
|
85
|
+
reset: Date.now() + 30_000,
|
|
86
|
+
});
|
|
87
|
+
mocks.blocked.mockReset().mockResolvedValue(null);
|
|
88
|
+
mocks.trust.mockReset().mockResolvedValue(new Map());
|
|
89
|
+
});
|
|
90
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
91
|
+
|
|
92
|
+
it('allows a verified native session with available per-session budget', async () => {
|
|
93
|
+
const key = verifiedKey();
|
|
94
|
+
mocks.trust.mockResolvedValue(new Map([[key, { m: 1, verified: true }]]));
|
|
95
|
+
mocks.limit.mockResolvedValue({
|
|
96
|
+
success: true,
|
|
97
|
+
limit: 60,
|
|
98
|
+
remaining: 59,
|
|
99
|
+
reset: Date.now() + 30_000,
|
|
100
|
+
});
|
|
101
|
+
expect(await request()).toBeNull();
|
|
102
|
+
expect(mocks.limit).toHaveBeenCalledWith(key);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('keeps unsupported separator whitespace aligned with server cache keys', async () => {
|
|
106
|
+
const header = `Bearer ${token}`;
|
|
107
|
+
const sessionSubject = (authorization: string) =>
|
|
108
|
+
buildAbuseRiskSubjects({
|
|
109
|
+
headers: { authorization },
|
|
110
|
+
userId: 'synthetic-user',
|
|
111
|
+
}).find((subject) => subject.subject_type === 'session');
|
|
112
|
+
expect(sessionSubject(`Bearer ${token}`)?.subject_key).toBe(verifiedKey());
|
|
113
|
+
expect(sessionSubject(header)).toBeUndefined();
|
|
114
|
+
mocks.trust.mockResolvedValue(
|
|
115
|
+
new Map([[verifiedKey(), { m: 1, verified: true }]])
|
|
116
|
+
);
|
|
117
|
+
const response = await request(header);
|
|
118
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
119
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:192.0.2.1');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('uses exactly the authenticated server key, preserving rate limits and Retry-After', async () => {
|
|
123
|
+
const key = verifiedKey();
|
|
124
|
+
mocks.trust.mockImplementation(async (keys: string[]) => {
|
|
125
|
+
expect(keys).toContain(key);
|
|
126
|
+
expect(JSON.stringify(keys)).not.toContain(token);
|
|
127
|
+
return new Map([[key, { m: 1, verified: true }]]);
|
|
128
|
+
});
|
|
129
|
+
const response = await request();
|
|
130
|
+
expect(response?.status).toBe(429);
|
|
131
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
132
|
+
'authenticated'
|
|
133
|
+
);
|
|
134
|
+
expect(response?.headers.get('Retry-After')).toBe('30');
|
|
135
|
+
expect(mocks.limit).toHaveBeenCalledWith(key);
|
|
136
|
+
expect(mocks.prefixes).toContain(
|
|
137
|
+
'proxy:infra:api:default:authenticated:get:minute'
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it.each([`Bearer forged`, `Bearer ${token}`, 'Bearer malformed token'])(
|
|
142
|
+
'unverified or evicted credentials retain the anonymous IP budget: %s',
|
|
143
|
+
async (header) => {
|
|
144
|
+
const response = await request(header);
|
|
145
|
+
expect(response?.status).toBe(429);
|
|
146
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
|
|
147
|
+
'anonymous'
|
|
148
|
+
);
|
|
149
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:192.0.2.1');
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
it('rotated credentials cannot inherit the old session cache entry', async () => {
|
|
154
|
+
mocks.trust.mockResolvedValue(
|
|
155
|
+
new Map([[verifiedKey(), { m: 1, verified: true }]])
|
|
156
|
+
);
|
|
157
|
+
const response = await request('Bearer rotated-session');
|
|
158
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
159
|
+
expect(mocks.limit).toHaveBeenCalledWith('ip:192.0.2.1');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('uses Bearer precedence even when a different trusted cookie is present', async () => {
|
|
163
|
+
const { buildProxySessionSubjectKey } = await import(
|
|
164
|
+
'../api-proxy-guard.js'
|
|
165
|
+
);
|
|
166
|
+
const cookieKey = await buildProxySessionSubjectKey(
|
|
167
|
+
'sb-test-auth-token',
|
|
168
|
+
'cookie'
|
|
169
|
+
);
|
|
170
|
+
mocks.trust.mockResolvedValue(
|
|
171
|
+
new Map([[cookieKey, { m: 1, verified: true }]])
|
|
172
|
+
);
|
|
173
|
+
const response = await request(
|
|
174
|
+
'Bearer forged',
|
|
175
|
+
'sb-test-auth-token=cookie'
|
|
176
|
+
);
|
|
177
|
+
expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('retains IP blocks even for verified native sessions', async () => {
|
|
181
|
+
mocks.trust.mockResolvedValue(
|
|
182
|
+
new Map([[verifiedKey(), { m: 1, verified: true }]])
|
|
183
|
+
);
|
|
184
|
+
mocks.blocked.mockResolvedValue({
|
|
185
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
186
|
+
});
|
|
187
|
+
const response = await request();
|
|
188
|
+
expect(response?.headers.get('X-Proxy-Block-Reason')).toBe(
|
|
189
|
+
'ip-already-blocked'
|
|
190
|
+
);
|
|
191
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
calendarColorForeground,
|
|
4
|
+
calendarEventColors,
|
|
5
|
+
calendarEventStyle,
|
|
6
|
+
} from '../calendar-event-colors';
|
|
7
|
+
|
|
8
|
+
const metadata = (inherited: boolean, background: unknown = '#abcdef') => ({
|
|
9
|
+
google_color: { version: 1, inherited, background },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('effective opaque calendar rendering', () => {
|
|
13
|
+
it('preserves explicit provider RGB instead of the canonical FK or source RGB', () => {
|
|
14
|
+
expect(
|
|
15
|
+
calendarEventStyle({
|
|
16
|
+
color: 'BLUE',
|
|
17
|
+
_calendarColor: '#ff0000',
|
|
18
|
+
scheduling_metadata: metadata(false, '#00ff88'),
|
|
19
|
+
})
|
|
20
|
+
).toEqual({
|
|
21
|
+
backgroundColor: '#00ff88',
|
|
22
|
+
borderColor: '#00ff88',
|
|
23
|
+
color: '#000000',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
it('updates inherited events from current source RGB before historical metadata', () => {
|
|
27
|
+
expect(
|
|
28
|
+
calendarEventColors({
|
|
29
|
+
_calendarColor: '#ff80ab',
|
|
30
|
+
scheduling_metadata: metadata(true, '#00ff88'),
|
|
31
|
+
}).background
|
|
32
|
+
).toBe('#ff80ab');
|
|
33
|
+
});
|
|
34
|
+
it('retains resolved inherited metadata when current source RGB is unavailable', () => {
|
|
35
|
+
expect(
|
|
36
|
+
calendarEventColors({
|
|
37
|
+
_calendarColor: 'rgba(0,0,0,.1)',
|
|
38
|
+
scheduling_metadata: metadata(true),
|
|
39
|
+
}).background
|
|
40
|
+
).toBe('#abcdef');
|
|
41
|
+
});
|
|
42
|
+
it('does not infer inheritance for unknown explicit or legacy rows', () => {
|
|
43
|
+
for (const scheduling_metadata of [
|
|
44
|
+
undefined,
|
|
45
|
+
metadata(false, null),
|
|
46
|
+
metadata(false, '#ff000080'),
|
|
47
|
+
]) {
|
|
48
|
+
expect(
|
|
49
|
+
calendarEventStyle({
|
|
50
|
+
color: 'GREEN',
|
|
51
|
+
_calendarColor: '#ff80ab',
|
|
52
|
+
scheduling_metadata,
|
|
53
|
+
}).backgroundColor
|
|
54
|
+
).toBe('#4caf50');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
it('retains legacy grey and stored grey RGB aliases', () => {
|
|
58
|
+
expect(calendarEventColors({ color: 'grey' }).background).toBe('#9e9e9e');
|
|
59
|
+
expect(calendarEventColors({ color: '#6b7280' }).background).toBe(
|
|
60
|
+
'#6b7280'
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
it('uses readable opaque text across pale and dark colors', () => {
|
|
64
|
+
expect(calendarColorForeground('#ffffff')).toBe('#000000');
|
|
65
|
+
expect(calendarColorForeground('#000000')).toBe('#ffffff');
|
|
66
|
+
for (const background of [
|
|
67
|
+
'#a4bdfc',
|
|
68
|
+
'#7ae7bf',
|
|
69
|
+
'#dbadff',
|
|
70
|
+
'#ff887c',
|
|
71
|
+
'#fbd75b',
|
|
72
|
+
'#ffb878',
|
|
73
|
+
'#46d6db',
|
|
74
|
+
'#e1e1e1',
|
|
75
|
+
'#5484ed',
|
|
76
|
+
'#51b749',
|
|
77
|
+
'#dc2127',
|
|
78
|
+
...[
|
|
79
|
+
'BLUE',
|
|
80
|
+
'RED',
|
|
81
|
+
'GREEN',
|
|
82
|
+
'YELLOW',
|
|
83
|
+
'PURPLE',
|
|
84
|
+
'PINK',
|
|
85
|
+
'ORANGE',
|
|
86
|
+
'INDIGO',
|
|
87
|
+
'CYAN',
|
|
88
|
+
'GRAY',
|
|
89
|
+
'grey',
|
|
90
|
+
'#6b7280',
|
|
91
|
+
].map((color) => calendarEventColors({ color }).background),
|
|
92
|
+
]) {
|
|
93
|
+
const text = calendarColorForeground(background);
|
|
94
|
+
const rgb = [1, 3, 5]
|
|
95
|
+
.map((i) => parseInt(background.slice(i, i + 2), 16) / 255)
|
|
96
|
+
.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
|
97
|
+
const l = rgb[0]! * 0.2126 + rgb[1]! * 0.7152 + rgb[2]! * 0.0722;
|
|
98
|
+
expect(
|
|
99
|
+
text === '#000000' ? (l + 0.05) / 0.05 : 1.05 / (l + 0.05)
|
|
100
|
+
).toBeGreaterThanOrEqual(4.5);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/api-proxy-guard.ts
CHANGED
|
@@ -886,19 +886,6 @@ function parseCookieHeaderEdge(
|
|
|
886
886
|
);
|
|
887
887
|
}
|
|
888
888
|
|
|
889
|
-
function getSessionAuthCookie(
|
|
890
|
-
req: NextRequest
|
|
891
|
-
): { name: string; value: string } | null {
|
|
892
|
-
const cookies = parseCookieHeaderEdge(req.headers.get('cookie'));
|
|
893
|
-
return (
|
|
894
|
-
cookies.find(
|
|
895
|
-
(cookie) =>
|
|
896
|
-
cookie.name === 'tuturuuu_app_session' ||
|
|
897
|
-
GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN.test(cookie.name)
|
|
898
|
-
) ?? null
|
|
899
|
-
);
|
|
900
|
-
}
|
|
901
|
-
|
|
902
889
|
export async function getProxySessionSubjectKeyFromCookieHeader(
|
|
903
890
|
cookieHeader: string | null
|
|
904
891
|
): Promise<string | null> {
|
|
@@ -969,10 +956,15 @@ async function resolveProxyIdentity(
|
|
|
969
956
|
ip: string
|
|
970
957
|
): Promise<ProxyIdentity> {
|
|
971
958
|
const normalizedIp = ip && ip !== 'unknown' ? ip : null;
|
|
972
|
-
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
|
|
959
|
+
// Match the server's verified session cache key; header presence grants no trust.
|
|
960
|
+
const bearer = req.headers
|
|
961
|
+
.get('authorization')
|
|
962
|
+
?.match(/^Bearer ([^\s]+)$/i)?.[1];
|
|
963
|
+
const sessionKey = bearer
|
|
964
|
+
? await buildProxySessionSubjectKey('bearer', bearer)
|
|
965
|
+
: await getProxySessionSubjectKeyFromCookieHeader(
|
|
966
|
+
req.headers.get('cookie')
|
|
967
|
+
);
|
|
976
968
|
|
|
977
969
|
return {
|
|
978
970
|
cidrKey: getCidrSubjectKeyEdge(normalizedIp),
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
opaqueGoogleColor,
|
|
3
|
+
readGoogleEventColor,
|
|
4
|
+
} from './google-calendar-colors';
|
|
5
|
+
|
|
6
|
+
/** Stable opaque fallback for first-party and legacy rows without provider RGB. */
|
|
7
|
+
const canonicalBackgrounds: Record<string, string> = {
|
|
8
|
+
BLUE: '#2196f3',
|
|
9
|
+
RED: '#f44336',
|
|
10
|
+
GREEN: '#4caf50',
|
|
11
|
+
YELLOW: '#ffeb3b',
|
|
12
|
+
PURPLE: '#9c27b0',
|
|
13
|
+
PINK: '#e91e63',
|
|
14
|
+
ORANGE: '#ff9800',
|
|
15
|
+
INDIGO: '#3f51b5',
|
|
16
|
+
CYAN: '#00bcd4',
|
|
17
|
+
GRAY: '#9e9e9e',
|
|
18
|
+
GREY: '#9e9e9e',
|
|
19
|
+
'#6B7280': '#6b7280',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ColorEvent = {
|
|
23
|
+
color?: string | null;
|
|
24
|
+
scheduling_metadata?: unknown;
|
|
25
|
+
_calendarColor?: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Choose the higher contrast of black/white (at least 4.5:1 for any RGB). */
|
|
29
|
+
export function calendarColorForeground(background: string): string {
|
|
30
|
+
const channels = [1, 3, 5].map((offset) => {
|
|
31
|
+
const value =
|
|
32
|
+
Number.parseInt(background.slice(offset, offset + 2), 16) / 255;
|
|
33
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
34
|
+
});
|
|
35
|
+
const luminance =
|
|
36
|
+
channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
|
|
37
|
+
return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05)
|
|
38
|
+
? '#000000'
|
|
39
|
+
: '#ffffff';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function calendarEventColors(event: ColorEvent) {
|
|
43
|
+
const google = readGoogleEventColor(event.scheduling_metadata);
|
|
44
|
+
// Only known inherited intent uses the current source calendar color.
|
|
45
|
+
// Explicit unknown colors and legacy rows never silently become inherited.
|
|
46
|
+
const background =
|
|
47
|
+
(google?.inherited ? opaqueGoogleColor(event._calendarColor) : null) ??
|
|
48
|
+
google?.background ??
|
|
49
|
+
canonicalBackgrounds[event.color?.trim().toUpperCase() ?? 'BLUE'] ??
|
|
50
|
+
canonicalBackgrounds.BLUE!;
|
|
51
|
+
return { background, foreground: calendarColorForeground(background) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Inline RGB wins over theme color classes without reducing fill opacity. */
|
|
55
|
+
export function calendarEventStyle(event: ColorEvent) {
|
|
56
|
+
const { background, foreground } = calendarEventColors(event);
|
|
57
|
+
return {
|
|
58
|
+
backgroundColor: background,
|
|
59
|
+
color: foreground,
|
|
60
|
+
borderColor: background,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -330,7 +330,19 @@ describe('application metadata coverage', () => {
|
|
|
330
330
|
)
|
|
331
331
|
.map((entry) => resolve(entry.parentPath, entry.name));
|
|
332
332
|
|
|
333
|
-
|
|
333
|
+
// Guard concrete entry points while allowing new public pages to opt in.
|
|
334
|
+
// A fixed layout count rejects valid additions without testing SEO behavior.
|
|
335
|
+
const criticalLayouts = [
|
|
336
|
+
'(landing)/layout.tsx',
|
|
337
|
+
'about/layout.tsx',
|
|
338
|
+
'products/tasks/layout.tsx',
|
|
339
|
+
'privacy/layout.tsx',
|
|
340
|
+
'contributors/layout.tsx',
|
|
341
|
+
'women-in-tech/layout.tsx',
|
|
342
|
+
].map((pathname) =>
|
|
343
|
+
resolve(repoRoot, 'apps/web/src/app/[locale]/(marketing)', pathname)
|
|
344
|
+
);
|
|
345
|
+
expect(marketingLayouts).toEqual(expect.arrayContaining(criticalLayouts));
|
|
334
346
|
for (const layoutPath of marketingLayouts) {
|
|
335
347
|
expect(readFileSync(layoutPath, 'utf8')).toMatch(/pathname:\s*['`]/);
|
|
336
348
|
}
|
package/src/email/client.ts
CHANGED
|
@@ -31,11 +31,7 @@ export const isExactTuturuuuDotComEmail = (email?: string | null): boolean => {
|
|
|
31
31
|
return /^[^\s@]+@tuturuuu\.com$/i.test(email.trim());
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
export const isTuturuuuReviewEmail = (email?: string | null): boolean => {
|
|
36
|
-
if (!email) return false;
|
|
37
|
-
return /^[^\s@]+@tutur3u\.com$/i.test(email.trim());
|
|
38
|
-
};
|
|
34
|
+
export { isTuturuuuReviewEmail } from './reviewer-domain.mjs';
|
|
39
35
|
|
|
40
36
|
export const isEmail = (text: string): boolean => {
|
|
41
37
|
const emailRegex =
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function isTuturuuuReviewEmail(email?: string | null): boolean;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
googleColorCompatibilityValue,
|
|
4
|
+
opaqueGoogleColor,
|
|
5
|
+
resolveGoogleEventColor,
|
|
6
|
+
} from './google-calendar-colors';
|
|
7
|
+
|
|
8
|
+
describe('Google event color resolution', () => {
|
|
9
|
+
it('preserves inherited custom calendar RGB instead of substituting BLUE', () => {
|
|
10
|
+
expect(
|
|
11
|
+
resolveGoogleEventColor(
|
|
12
|
+
{ colorId: null },
|
|
13
|
+
{
|
|
14
|
+
calendarId: 'personal',
|
|
15
|
+
calendarBackground: '#F691B2',
|
|
16
|
+
calendarForeground: '#000000',
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
).toEqual({
|
|
20
|
+
version: 1,
|
|
21
|
+
calendar_id: 'personal',
|
|
22
|
+
color_id: null,
|
|
23
|
+
event_label_id: null,
|
|
24
|
+
inherited: true,
|
|
25
|
+
background: '#f691b2',
|
|
26
|
+
foreground: '#000000',
|
|
27
|
+
resolution: 'calendar',
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
it('uses all current event definitions, independently from calendar palette IDs', () => {
|
|
31
|
+
for (let id = 1; id <= 11; id++) {
|
|
32
|
+
const key = String(id);
|
|
33
|
+
expect(
|
|
34
|
+
resolveGoogleEventColor(
|
|
35
|
+
{ colorId: key },
|
|
36
|
+
{
|
|
37
|
+
eventColors: {
|
|
38
|
+
[key]: { background: '#039BE5', foreground: '#ffffff' },
|
|
39
|
+
},
|
|
40
|
+
calendarBackground: '#f691b2',
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
).toMatchObject({
|
|
44
|
+
color_id: key,
|
|
45
|
+
background: '#039be5',
|
|
46
|
+
inherited: false,
|
|
47
|
+
resolution: 'event',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
expect(googleColorCompatibilityValue('7')).toBe('CYAN');
|
|
51
|
+
expect(googleColorCompatibilityValue('11')).toBe('RED');
|
|
52
|
+
});
|
|
53
|
+
it('prioritizes calendar-scoped labels over legacy color IDs', () => {
|
|
54
|
+
expect(
|
|
55
|
+
resolveGoogleEventColor(
|
|
56
|
+
{ eventLabelId: 'custom-label', colorId: '7' },
|
|
57
|
+
{
|
|
58
|
+
eventLabels: [{ id: 'custom-label', backgroundColor: '#a123bc' }],
|
|
59
|
+
eventColors: { '7': { background: '#039be5' } },
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
).toMatchObject({
|
|
63
|
+
event_label_id: 'custom-label',
|
|
64
|
+
color_id: '7',
|
|
65
|
+
background: '#a123bc',
|
|
66
|
+
resolution: 'label',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
it('retains unknown explicit IDs and reports unresolved instead of inherited', () => {
|
|
70
|
+
expect(
|
|
71
|
+
resolveGoogleEventColor(
|
|
72
|
+
{ eventLabelId: 'unknown' },
|
|
73
|
+
{ calendarBackground: '#f691b2' }
|
|
74
|
+
)
|
|
75
|
+
).toMatchObject({
|
|
76
|
+
inherited: false,
|
|
77
|
+
event_label_id: 'unknown',
|
|
78
|
+
background: null,
|
|
79
|
+
resolution: 'unresolved',
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
it('accepts only opaque RGB for rendering', () => {
|
|
83
|
+
expect(opaqueGoogleColor('#039BE5')).toBe('#039be5');
|
|
84
|
+
for (const value of ['#039be580', 'red', 'url(x)', '#fff'])
|
|
85
|
+
expect(opaqueGoogleColor(value)).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
|
|
2
|
+
|
|
3
|
+
export type GoogleColorDefinition = {
|
|
4
|
+
background?: string | null;
|
|
5
|
+
foreground?: string | null;
|
|
6
|
+
};
|
|
7
|
+
export type GoogleColorContext = {
|
|
8
|
+
calendarId?: string;
|
|
9
|
+
calendarBackground?: string | null;
|
|
10
|
+
calendarForeground?: string | null;
|
|
11
|
+
eventColors?: Record<string, GoogleColorDefinition> | null;
|
|
12
|
+
eventLabels?: { id?: string | null; backgroundColor?: string | null }[];
|
|
13
|
+
};
|
|
14
|
+
export type GoogleEventColor = {
|
|
15
|
+
version: 1;
|
|
16
|
+
calendar_id: string | null;
|
|
17
|
+
color_id: string | null;
|
|
18
|
+
event_label_id: string | null;
|
|
19
|
+
inherited: boolean;
|
|
20
|
+
background: string | null;
|
|
21
|
+
foreground: string | null;
|
|
22
|
+
resolution: 'calendar' | 'event' | 'label' | 'unresolved';
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Compatibility only for the existing canonical-color FK. Rendering uses RGB.
|
|
26
|
+
const LEGACY_EVENT_COLORS: Record<string, SupportedColor> = {
|
|
27
|
+
'1': 'INDIGO',
|
|
28
|
+
'2': 'GREEN',
|
|
29
|
+
'3': 'PURPLE',
|
|
30
|
+
'4': 'PINK',
|
|
31
|
+
'5': 'YELLOW',
|
|
32
|
+
'6': 'ORANGE',
|
|
33
|
+
'7': 'CYAN',
|
|
34
|
+
'8': 'GRAY',
|
|
35
|
+
'9': 'BLUE',
|
|
36
|
+
'10': 'GREEN',
|
|
37
|
+
'11': 'RED',
|
|
38
|
+
};
|
|
39
|
+
export const GOOGLE_COLOR_IDS: Record<SupportedColor, string> = {
|
|
40
|
+
INDIGO: '1',
|
|
41
|
+
GREEN: '2',
|
|
42
|
+
PURPLE: '3',
|
|
43
|
+
PINK: '4',
|
|
44
|
+
YELLOW: '5',
|
|
45
|
+
ORANGE: '6',
|
|
46
|
+
CYAN: '7',
|
|
47
|
+
GRAY: '8',
|
|
48
|
+
BLUE: '9',
|
|
49
|
+
RED: '11',
|
|
50
|
+
};
|
|
51
|
+
export function googleColorCompatibilityValue(colorId?: string | null) {
|
|
52
|
+
return colorId ? (LEGACY_EVENT_COLORS[colorId] ?? 'BLUE') : 'BLUE';
|
|
53
|
+
}
|
|
54
|
+
export function opaqueGoogleColor(value: unknown): string | null {
|
|
55
|
+
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
|
|
56
|
+
? value.toLowerCase()
|
|
57
|
+
: null;
|
|
58
|
+
}
|
|
59
|
+
export function resolveGoogleEventColor(
|
|
60
|
+
event: { colorId?: string | null; eventLabelId?: string | null },
|
|
61
|
+
context: GoogleColorContext = {}
|
|
62
|
+
): GoogleEventColor {
|
|
63
|
+
const labelId = event.eventLabelId || null;
|
|
64
|
+
const colorId = event.colorId && event.colorId !== '0' ? event.colorId : null;
|
|
65
|
+
const inherited = !labelId && !colorId;
|
|
66
|
+
const label = context.eventLabels?.find((entry) => entry.id === labelId);
|
|
67
|
+
const definition = colorId ? context.eventColors?.[colorId] : undefined;
|
|
68
|
+
// An unknown explicit identity must not silently become an inherited color.
|
|
69
|
+
const background = opaqueGoogleColor(
|
|
70
|
+
labelId
|
|
71
|
+
? label?.backgroundColor
|
|
72
|
+
: colorId
|
|
73
|
+
? definition?.background
|
|
74
|
+
: context.calendarBackground
|
|
75
|
+
);
|
|
76
|
+
return {
|
|
77
|
+
version: 1,
|
|
78
|
+
calendar_id: context.calendarId ?? null,
|
|
79
|
+
color_id: colorId,
|
|
80
|
+
event_label_id: labelId,
|
|
81
|
+
inherited,
|
|
82
|
+
background,
|
|
83
|
+
foreground: labelId
|
|
84
|
+
? null
|
|
85
|
+
: opaqueGoogleColor(
|
|
86
|
+
colorId ? definition?.foreground : context.calendarForeground
|
|
87
|
+
),
|
|
88
|
+
resolution: !background
|
|
89
|
+
? 'unresolved'
|
|
90
|
+
: labelId
|
|
91
|
+
? 'label'
|
|
92
|
+
: colorId
|
|
93
|
+
? 'event'
|
|
94
|
+
: 'calendar',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Provider metadata is read defensively; client metadata is never a write command. */
|
|
99
|
+
export function readGoogleEventColor(
|
|
100
|
+
metadata: unknown
|
|
101
|
+
): GoogleEventColor | null {
|
|
102
|
+
if (!metadata || typeof metadata !== 'object') return null;
|
|
103
|
+
const color = (metadata as Record<string, unknown>).google_color;
|
|
104
|
+
if (!color || typeof color !== 'object') return null;
|
|
105
|
+
const entry = color as GoogleEventColor;
|
|
106
|
+
if (entry.version !== 1 || typeof entry.inherited !== 'boolean') return null;
|
|
107
|
+
return {
|
|
108
|
+
...entry,
|
|
109
|
+
background: opaqueGoogleColor(entry.background),
|
|
110
|
+
foreground: opaqueGoogleColor(entry.foreground),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -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.
|
|
10
|
+
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.65.0'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('preserves the source timestamp in builtAt and derives a short hash', () => {
|
package/src/platform-release.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
|
|
2
2
|
|
|
3
|
-
export const TUTURUUU_PLATFORM_VERSION = '0.
|
|
3
|
+
export const TUTURUUU_PLATFORM_VERSION = '0.65.0'; // x-release-please-version
|
|
4
4
|
|
|
5
5
|
export type PlatformBuildMetadataInput = {
|
|
6
6
|
/** ISO timestamp of the source commit used for the build, not deployment time. */
|