@tuturuuu/utils 0.13.0 → 0.15.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 +56 -0
- package/biome.json +1 -1
- package/package.json +10 -10
- package/src/__tests__/app-url.test.ts +4 -0
- package/src/calendar-settings-resolver.ts +200 -0
- package/src/internal-domains.ts +32 -0
- package/src/launchable-apps.test.ts +44 -0
- package/src/launchable-apps.ts +52 -39
- package/src/platform-release.test.ts +2 -2
- package/src/platform-release.ts +3 -1
- package/src/portless.ts +3 -0
- package/src/shared-cookie.test.ts +114 -0
- package/src/shared-cookie.ts +235 -0
- package/src/topic-announcements.ts +1 -0
- package/src/user-helper.ts +6 -95
- package/src/workspace-user-link.test.ts +53 -0
- package/src/workspace-user-link.ts +122 -0
- package/src/yjs-helper.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.15.0](https://github.com/tutur3u/platform/compare/utils-v0.14.0...utils-v0.15.0) (2026-07-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **contacts:** migrate users attendance; add satellite-safe workspace-user link helper ([8f53453](https://github.com/tutur3u/platform/commit/8f534533793af8d25e7c2eca6e0147e459d78bbe))
|
|
9
|
+
* **contacts:** migrate users topic-announcements module ([01e19ab](https://github.com/tutur3u/platform/commit/01e19ab60461ccd94a0374bed74ce6e4d5d38a3b))
|
|
10
|
+
* **contacts:** scaffold contacts.tuturuuu.com satellite shell + monorepo registration ([7e335fc](https://github.com/tutur3u/platform/commit/7e335fc036c4a45ed189095ecd10a43ee002294b))
|
|
11
|
+
* **infrastructure:** add abuse protection controls ([0e09692](https://github.com/tutur3u/platform/commit/0e09692a553cb10fbbb521e921951c2a6863281d))
|
|
12
|
+
* **infrastructure:** add rate-limit appeals ([f1e50e9](https://github.com/tutur3u/platform/commit/f1e50e9fd805dca6c80da8daab60ed24199f24ab))
|
|
13
|
+
* **pay:** scaffold apps/pay satellite (pay.tuturuuu.com, port 7826) ([3d6e45c](https://github.com/tutur3u/platform/commit/3d6e45cca6a315fe67213e438144b73103bae2f5))
|
|
14
|
+
* **tools:** add apps/tools public tools app, remove apps/qr ([204aae8](https://github.com/tutur3u/platform/commit/204aae89a04b4163f2859561f8e832526642e271))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* **auth:** share account preference cookies ([8c1848a](https://github.com/tutur3u/platform/commit/8c1848a941c1b3f91337104c975e4bc0d8f68fc9))
|
|
20
|
+
* **ci:** keep cache components enabled in production ([75e6e2b](https://github.com/tutur3u/platform/commit/75e6e2bbbf6fa432bc496ee95b75ade5f8847f9f))
|
|
21
|
+
* **cli:** preserve markdown tables in task descriptions ([9faf8ba](https://github.com/tutur3u/platform/commit/9faf8ba44b92565a9dfc2949c496654fc1fea9b3))
|
|
22
|
+
* **infrastructure:** default docker web to native builds ([7d89f00](https://github.com/tutur3u/platform/commit/7d89f004f75ab12eddc3cb92df5a1e6a7255fe0d))
|
|
23
|
+
* **infrastructure:** restore canonical auth host access ([9f6715f](https://github.com/tutur3u/platform/commit/9f6715fb1c5a89598556cba33b16938b76995fc6))
|
|
24
|
+
* restore apps launcher categories ([5c5b571](https://github.com/tutur3u/platform/commit/5c5b571ce920013bcfa5f327c4bb208ea9fbf23e))
|
|
25
|
+
* **satellite:** widen apps launcher grid ([90e3106](https://github.com/tutur3u/platform/commit/90e31066830f535a7fdd0f7bc249e5bc8b273cfc))
|
|
26
|
+
* **security:** limit auth recovery code attempts ([72d454c](https://github.com/tutur3u/platform/commit/72d454c507b0d4b7e69c7b38ad2099dbb83193b2))
|
|
27
|
+
* **tasks:** route native personal task moves normally ([5bbba3c](https://github.com/tutur3u/platform/commit/5bbba3c3f193e722cc87cdfe2eb2734f61442cd0))
|
|
28
|
+
* update launchable app catalog ([cb31207](https://github.com/tutur3u/platform/commit/cb312076aee227de9a8f99105d681911d14a63ac))
|
|
29
|
+
* **web:** fail open without redis ([74bfcc7](https://github.com/tutur3u/platform/commit/74bfcc7ea5f188a56f49802c6a48bde0b109e02d))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Performance Improvements
|
|
33
|
+
|
|
34
|
+
* **ci:** enable repository-wide remote caching ([6250f91](https://github.com/tutur3u/platform/commit/6250f91d745ef987a4fc86c797aedf41542f421b))
|
|
35
|
+
|
|
36
|
+
## [0.14.0](https://github.com/tutur3u/platform/compare/utils-v0.13.0...utils-v0.14.0) (2026-07-11)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
### Features
|
|
40
|
+
|
|
41
|
+
* **contacts:** migrate users attendance; add satellite-safe workspace-user link helper ([8f53453](https://github.com/tutur3u/platform/commit/8f534533793af8d25e7c2eca6e0147e459d78bbe))
|
|
42
|
+
* **contacts:** migrate users topic-announcements module ([01e19ab](https://github.com/tutur3u/platform/commit/01e19ab60461ccd94a0374bed74ce6e4d5d38a3b))
|
|
43
|
+
* **contacts:** scaffold contacts.tuturuuu.com satellite shell + monorepo registration ([7e335fc](https://github.com/tutur3u/platform/commit/7e335fc036c4a45ed189095ecd10a43ee002294b))
|
|
44
|
+
* **pay:** scaffold apps/pay satellite (pay.tuturuuu.com, port 7826) ([3d6e45c](https://github.com/tutur3u/platform/commit/3d6e45cca6a315fe67213e438144b73103bae2f5))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
### Bug Fixes
|
|
48
|
+
|
|
49
|
+
* **auth:** share account preference cookies ([8c1848a](https://github.com/tutur3u/platform/commit/8c1848a941c1b3f91337104c975e4bc0d8f68fc9))
|
|
50
|
+
* restore apps launcher categories ([5c5b571](https://github.com/tutur3u/platform/commit/5c5b571ce920013bcfa5f327c4bb208ea9fbf23e))
|
|
51
|
+
* **satellite:** widen apps launcher grid ([90e3106](https://github.com/tutur3u/platform/commit/90e31066830f535a7fdd0f7bc249e5bc8b273cfc))
|
|
52
|
+
* update launchable app catalog ([cb31207](https://github.com/tutur3u/platform/commit/cb312076aee227de9a8f99105d681911d14a63ac))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
### Performance Improvements
|
|
56
|
+
|
|
57
|
+
* **ci:** enable repository-wide remote caching ([6250f91](https://github.com/tutur3u/platform/commit/6250f91d745ef987a4fc86c797aedf41542f421b))
|
|
58
|
+
|
|
3
59
|
## [0.13.0](https://github.com/tutur3u/platform/compare/utils-v0.12.1...utils-v0.13.0) (2026-07-05)
|
|
4
60
|
|
|
5
61
|
|
package/biome.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.15.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"@noble/ciphers": "^2.2.0",
|
|
21
21
|
"@noble/hashes": "^2.2.0",
|
|
22
22
|
"@tanstack/react-query": "^5.101.2",
|
|
23
|
-
"@tiptap/core": "3.27.
|
|
24
|
-
"@tiptap/react": "3.27.
|
|
23
|
+
"@tiptap/core": "3.27.3",
|
|
24
|
+
"@tiptap/react": "3.27.3",
|
|
25
25
|
"@tuturuuu/google": "0.0.3",
|
|
26
26
|
"@tuturuuu/icons": "0.0.6",
|
|
27
|
-
"@tuturuuu/internal-api": "0.
|
|
27
|
+
"@tuturuuu/internal-api": "0.17.0",
|
|
28
28
|
"@tuturuuu/supabase": "0.4.0",
|
|
29
29
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
30
|
"@upstash/redis": "^1.38.0",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"effect": "^3.21.4",
|
|
36
36
|
"moment": "^2.30.1",
|
|
37
37
|
"next": "^16.3.0-preview.5",
|
|
38
|
-
"prosemirror-model": "^1.25.
|
|
38
|
+
"prosemirror-model": "^1.25.11",
|
|
39
39
|
"react": "^19.2.7",
|
|
40
40
|
"react-dom": "^19.2.7",
|
|
41
41
|
"tailwind-merge": "^3.6.0",
|
|
@@ -45,15 +45,15 @@
|
|
|
45
45
|
"zod": "^4.4.3"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@tuturuuu/types": "0.
|
|
48
|
+
"@tuturuuu/types": "0.16.0",
|
|
49
49
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
50
50
|
"@types/diff": "^8.0.0",
|
|
51
|
-
"@types/node": "^26.
|
|
51
|
+
"@types/node": "^26.1.1",
|
|
52
52
|
"@types/react": "^19.2.17",
|
|
53
53
|
"@vitejs/plugin-react": "^6.0.3",
|
|
54
|
-
"typescript": "7.0.
|
|
55
|
-
"vite": "^8.1.
|
|
56
|
-
"vitest": "^4.1.
|
|
54
|
+
"typescript": "7.0.2",
|
|
55
|
+
"vite": "^8.1.4",
|
|
56
|
+
"vitest": "^4.1.10"
|
|
57
57
|
},
|
|
58
58
|
"exports": {
|
|
59
59
|
"./timezones": "./src/timezones.json",
|
|
@@ -231,6 +231,7 @@ describe('getLocalInternalAppUrl', () => {
|
|
|
231
231
|
['cms', 'https://cms.tuturuuu.localhost'],
|
|
232
232
|
['calendar', 'https://calendar.tuturuuu.localhost'],
|
|
233
233
|
['chat', 'https://chat.tuturuuu.localhost'],
|
|
234
|
+
['docs', 'https://docs.tuturuuu.localhost'],
|
|
234
235
|
['drive', 'https://drive.tuturuuu.localhost'],
|
|
235
236
|
['qr', 'https://qr.tuturuuu.localhost'],
|
|
236
237
|
['tools', 'https://tools.tuturuuu.localhost'],
|
|
@@ -290,6 +291,8 @@ describe('Portless app origin registry', () => {
|
|
|
290
291
|
'https://calendar.tuturuuu.localhost',
|
|
291
292
|
'https://chat.tuturuuu.localhost',
|
|
292
293
|
'https://cms.tuturuuu.localhost',
|
|
294
|
+
'https://contacts.tuturuuu.localhost',
|
|
295
|
+
'https://docs.tuturuuu.localhost',
|
|
293
296
|
'https://drive.tuturuuu.localhost',
|
|
294
297
|
'https://external.tuturuuu.localhost',
|
|
295
298
|
'https://finance.tuturuuu.localhost',
|
|
@@ -302,6 +305,7 @@ describe('Portless app origin registry', () => {
|
|
|
302
305
|
'https://meet.tuturuuu.localhost',
|
|
303
306
|
'https://mind.tuturuuu.localhost',
|
|
304
307
|
'https://nova.tuturuuu.localhost',
|
|
308
|
+
'https://pay.tuturuuu.localhost',
|
|
305
309
|
'https://tuturuuu.localhost',
|
|
306
310
|
'https://playground.tuturuuu.localhost',
|
|
307
311
|
'https://qr.tuturuuu.localhost',
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar Settings Resolver
|
|
3
|
+
*
|
|
4
|
+
* Implements priority system for calendar preferences:
|
|
5
|
+
* User settings > Workspace settings > Auto-detection
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
type FirstDayOfWeek = 'auto' | 'sunday' | 'monday' | 'saturday';
|
|
9
|
+
|
|
10
|
+
interface CalendarSettings {
|
|
11
|
+
timezone: string;
|
|
12
|
+
firstDayOfWeek: FirstDayOfWeek;
|
|
13
|
+
timeFormat: '12h' | '24h';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CalendarUserSettings {
|
|
17
|
+
timezone?: string | null;
|
|
18
|
+
first_day_of_week?: string | null;
|
|
19
|
+
time_format?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CalendarWorkspaceSettings {
|
|
23
|
+
timezone?: string | null;
|
|
24
|
+
first_day_of_week?: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detects the system timezone using Intl API
|
|
29
|
+
*/
|
|
30
|
+
export function detectSystemTimezone(): string {
|
|
31
|
+
try {
|
|
32
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
33
|
+
} catch {
|
|
34
|
+
// Fallback to UTC if detection fails
|
|
35
|
+
return 'UTC';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Detects the first day of week based on locale
|
|
41
|
+
* Vietnamese: Monday
|
|
42
|
+
* US/Canada: Sunday
|
|
43
|
+
* Middle East: Saturday
|
|
44
|
+
*/
|
|
45
|
+
export function detectLocaleFirstDay(locale?: string): FirstDayOfWeek {
|
|
46
|
+
const userLocale =
|
|
47
|
+
locale || (typeof navigator !== 'undefined' ? navigator.language : 'en-US');
|
|
48
|
+
|
|
49
|
+
// Vietnamese locale uses Monday as first day
|
|
50
|
+
if (userLocale.startsWith('vi')) {
|
|
51
|
+
return 'monday';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// US, Canada, and some other countries use Sunday
|
|
55
|
+
if (userLocale.startsWith('en-US') || userLocale.startsWith('en-CA')) {
|
|
56
|
+
return 'sunday';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Middle Eastern countries typically use Saturday
|
|
60
|
+
if (userLocale.startsWith('ar') || userLocale.startsWith('he')) {
|
|
61
|
+
return 'saturday';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Most European and other countries use Monday
|
|
65
|
+
return 'monday';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Detects the preferred time format based on locale
|
|
70
|
+
* Uses Intl.DateTimeFormat to determine if the locale uses 12h or 24h format
|
|
71
|
+
*/
|
|
72
|
+
export function detectLocaleTimeFormat(locale?: string): '12h' | '24h' {
|
|
73
|
+
const userLocale =
|
|
74
|
+
locale || (typeof navigator !== 'undefined' ? navigator.language : 'en-US');
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Use Intl.DateTimeFormat to detect the locale's preferred format
|
|
78
|
+
const formatter = new Intl.DateTimeFormat(userLocale, { hour: 'numeric' });
|
|
79
|
+
const parts = formatter.formatToParts(new Date(2000, 0, 1, 13, 0));
|
|
80
|
+
const hourPart = parts.find((p) => p.type === 'hour');
|
|
81
|
+
|
|
82
|
+
// If hour is "1" instead of "13", it's 12-hour format
|
|
83
|
+
return hourPart?.value === '1' ? '12h' : '24h';
|
|
84
|
+
} catch {
|
|
85
|
+
// Fallback to 12h for English locales, 24h for others
|
|
86
|
+
return userLocale.startsWith('en') ? '12h' : '24h';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolves the effective time format based on user setting
|
|
92
|
+
*/
|
|
93
|
+
export function resolveTimeFormat(
|
|
94
|
+
user?: Pick<CalendarUserSettings, 'time_format'> | null,
|
|
95
|
+
locale?: string
|
|
96
|
+
): '12h' | '24h' {
|
|
97
|
+
// User setting takes priority
|
|
98
|
+
if (user?.time_format && user.time_format !== 'auto') {
|
|
99
|
+
return user.time_format as '12h' | '24h';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Auto-detection based on locale
|
|
103
|
+
return detectLocaleTimeFormat(locale);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolves the effective timezone based on priority system
|
|
108
|
+
*/
|
|
109
|
+
export function resolveTimezone(
|
|
110
|
+
user?: Pick<CalendarUserSettings, 'timezone'> | null,
|
|
111
|
+
workspace?: Pick<CalendarWorkspaceSettings, 'timezone'> | null
|
|
112
|
+
): string {
|
|
113
|
+
// Priority 1: User setting
|
|
114
|
+
if (user?.timezone && user.timezone !== 'auto') {
|
|
115
|
+
return user.timezone;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Priority 2: Workspace setting
|
|
119
|
+
if (workspace?.timezone && workspace.timezone !== 'auto') {
|
|
120
|
+
return workspace.timezone;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Priority 3: Auto-detection
|
|
124
|
+
return detectSystemTimezone();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolves the effective first day of week based on priority system
|
|
129
|
+
*/
|
|
130
|
+
export function resolveFirstDayOfWeek(
|
|
131
|
+
user?: Pick<CalendarUserSettings, 'first_day_of_week'> | null,
|
|
132
|
+
workspace?: Pick<CalendarWorkspaceSettings, 'first_day_of_week'> | null,
|
|
133
|
+
locale?: string
|
|
134
|
+
): FirstDayOfWeek {
|
|
135
|
+
// Priority 1: User setting
|
|
136
|
+
if (user?.first_day_of_week && user.first_day_of_week !== 'auto') {
|
|
137
|
+
return user.first_day_of_week as FirstDayOfWeek;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Priority 2: Workspace setting
|
|
141
|
+
if (workspace?.first_day_of_week && workspace.first_day_of_week !== 'auto') {
|
|
142
|
+
return workspace.first_day_of_week as FirstDayOfWeek;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Priority 3: Auto-detection based on locale
|
|
146
|
+
return detectLocaleFirstDay(locale);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolves all calendar settings at once
|
|
151
|
+
*/
|
|
152
|
+
export function resolveCalendarSettings(
|
|
153
|
+
user?: CalendarUserSettings | null,
|
|
154
|
+
workspace?: CalendarWorkspaceSettings | null,
|
|
155
|
+
locale?: string
|
|
156
|
+
): CalendarSettings {
|
|
157
|
+
return {
|
|
158
|
+
timezone: resolveTimezone(user, workspace),
|
|
159
|
+
firstDayOfWeek: resolveFirstDayOfWeek(user, workspace, locale),
|
|
160
|
+
timeFormat: resolveTimeFormat(user, locale),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Converts first day of week string to number (for Date compatibility)
|
|
166
|
+
* 0 = Sunday, 1 = Monday, 6 = Saturday
|
|
167
|
+
*/
|
|
168
|
+
export function firstDayToNumber(day: FirstDayOfWeek, locale?: string): number {
|
|
169
|
+
if (day === 'auto') {
|
|
170
|
+
day = detectLocaleFirstDay(locale);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
switch (day) {
|
|
174
|
+
case 'sunday':
|
|
175
|
+
return 0;
|
|
176
|
+
case 'monday':
|
|
177
|
+
return 1;
|
|
178
|
+
case 'saturday':
|
|
179
|
+
return 6;
|
|
180
|
+
default:
|
|
181
|
+
return 1; // Default to Monday
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Converts number to first day of week string
|
|
187
|
+
* 0 = Sunday, 1 = Monday, 6 = Saturday
|
|
188
|
+
*/
|
|
189
|
+
export function numberToFirstDay(num: number): FirstDayOfWeek {
|
|
190
|
+
switch (num) {
|
|
191
|
+
case 0:
|
|
192
|
+
return 'sunday';
|
|
193
|
+
case 1:
|
|
194
|
+
return 'monday';
|
|
195
|
+
case 6:
|
|
196
|
+
return 'saturday';
|
|
197
|
+
default:
|
|
198
|
+
return 'monday'; // Default to Monday
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/internal-domains.ts
CHANGED
|
@@ -16,6 +16,10 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
|
|
|
16
16
|
name: 'cms',
|
|
17
17
|
url: 'https://cms.tuturuuu.com',
|
|
18
18
|
},
|
|
19
|
+
{
|
|
20
|
+
name: 'docs',
|
|
21
|
+
url: 'https://docs.tuturuuu.com',
|
|
22
|
+
},
|
|
19
23
|
{
|
|
20
24
|
name: 'calendar',
|
|
21
25
|
url: 'https://calendar.tuturuuu.com',
|
|
@@ -96,6 +100,14 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
|
|
|
96
100
|
name: 'teach',
|
|
97
101
|
url: 'https://teach.tuturuuu.com',
|
|
98
102
|
},
|
|
103
|
+
{
|
|
104
|
+
name: 'pay',
|
|
105
|
+
url: 'https://pay.tuturuuu.com',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'contacts',
|
|
109
|
+
url: 'https://contacts.tuturuuu.com',
|
|
110
|
+
},
|
|
99
111
|
{
|
|
100
112
|
name: 'hive',
|
|
101
113
|
url: 'https://hive.tuturuuu.com',
|
|
@@ -119,6 +131,10 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
|
|
|
119
131
|
name: 'cms',
|
|
120
132
|
url: getTuturuuuPortlessAppOrigin('cms'),
|
|
121
133
|
},
|
|
134
|
+
{
|
|
135
|
+
name: 'docs',
|
|
136
|
+
url: getTuturuuuPortlessAppOrigin('docs'),
|
|
137
|
+
},
|
|
122
138
|
{
|
|
123
139
|
name: 'calendar',
|
|
124
140
|
url: getTuturuuuPortlessAppOrigin('calendar'),
|
|
@@ -191,6 +207,14 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
|
|
|
191
207
|
name: 'teach',
|
|
192
208
|
url: getTuturuuuPortlessAppOrigin('teach'),
|
|
193
209
|
},
|
|
210
|
+
{
|
|
211
|
+
name: 'pay',
|
|
212
|
+
url: getTuturuuuPortlessAppOrigin('pay'),
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'contacts',
|
|
216
|
+
url: getTuturuuuPortlessAppOrigin('contacts'),
|
|
217
|
+
},
|
|
194
218
|
{
|
|
195
219
|
name: 'hive',
|
|
196
220
|
url: getTuturuuuPortlessAppOrigin('hive'),
|
|
@@ -286,6 +310,14 @@ export const LOCALHOST_INTERNAL_APP_DOMAINS = [
|
|
|
286
310
|
name: 'teach',
|
|
287
311
|
url: 'http://localhost:7813',
|
|
288
312
|
},
|
|
313
|
+
{
|
|
314
|
+
name: 'pay',
|
|
315
|
+
url: 'http://localhost:7826',
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: 'contacts',
|
|
319
|
+
url: 'http://localhost:7827',
|
|
320
|
+
},
|
|
289
321
|
{
|
|
290
322
|
name: 'hive',
|
|
291
323
|
url: 'http://localhost:7814',
|
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
getLaunchableApp,
|
|
4
4
|
getLaunchableAppByTitle,
|
|
5
5
|
getLaunchableAppOrigin,
|
|
6
|
+
LAUNCHABLE_APP_CATEGORIES,
|
|
7
|
+
LAUNCHABLE_APPS,
|
|
6
8
|
type LaunchableWorkspace,
|
|
7
9
|
resolveLaunchableAppPath,
|
|
8
10
|
resolveLaunchableAppUrl,
|
|
@@ -27,6 +29,39 @@ describe('launchable apps', () => {
|
|
|
27
29
|
expect(getLaunchableAppByTitle('apps gateway')?.slug).toBe('apps');
|
|
28
30
|
});
|
|
29
31
|
|
|
32
|
+
it('keeps retired apps out of the catalog and pins category overrides', () => {
|
|
33
|
+
expect(LAUNCHABLE_APPS.map((app) => app.slug)).not.toContain('external');
|
|
34
|
+
expect(LAUNCHABLE_APPS.map((app) => app.slug)).not.toContain('playground');
|
|
35
|
+
expect(LAUNCHABLE_APP_CATEGORIES).not.toContain('core');
|
|
36
|
+
expect(LAUNCHABLE_APP_CATEGORIES).not.toContain('content');
|
|
37
|
+
expect(LAUNCHABLE_APP_CATEGORIES).not.toContain('developer');
|
|
38
|
+
expect(LAUNCHABLE_APP_CATEGORIES).toContain('miscellaneous');
|
|
39
|
+
expect(getLaunchableApp('external')).toBeNull();
|
|
40
|
+
expect(getLaunchableApp('playground')).toBeNull();
|
|
41
|
+
expect(getLaunchableApp('platform')?.category).toBe('productivity');
|
|
42
|
+
expect(getLaunchableApp('apps')?.category).toBe('miscellaneous');
|
|
43
|
+
expect(getLaunchableApp('cms')?.category).toBe('operations');
|
|
44
|
+
expect(getLaunchableApp('rewise')?.category).toBe('ai');
|
|
45
|
+
expect(getLaunchableApp('docs')?.category).toBe('miscellaneous');
|
|
46
|
+
expect(getLaunchableApp('tools')?.category).toBe('miscellaneous');
|
|
47
|
+
expect(getLaunchableApp('shortener')?.category).toBe('miscellaneous');
|
|
48
|
+
expect(
|
|
49
|
+
LAUNCHABLE_APPS.filter((app) => app.category === 'productivity').map(
|
|
50
|
+
(app) => app.slug
|
|
51
|
+
)[0]
|
|
52
|
+
).toBe('platform');
|
|
53
|
+
expect(
|
|
54
|
+
LAUNCHABLE_APPS.filter((app) => app.category === 'operations').map(
|
|
55
|
+
(app) => app.slug
|
|
56
|
+
)
|
|
57
|
+
).not.toContain('apps');
|
|
58
|
+
expect(
|
|
59
|
+
LAUNCHABLE_APPS.filter((app) => app.category === 'miscellaneous').map(
|
|
60
|
+
(app) => app.slug
|
|
61
|
+
)[0]
|
|
62
|
+
).toBe('apps');
|
|
63
|
+
});
|
|
64
|
+
|
|
30
65
|
it('resolves production, Portless, and local origins', () => {
|
|
31
66
|
const app = getLaunchableApp('calendar');
|
|
32
67
|
|
|
@@ -46,6 +81,15 @@ describe('launchable apps', () => {
|
|
|
46
81
|
expect(getLaunchableAppOrigin(meet!, { environment: 'production' })).toBe(
|
|
47
82
|
'https://meet.tuturuuu.com'
|
|
48
83
|
);
|
|
84
|
+
|
|
85
|
+
const docs = getLaunchableApp('docs');
|
|
86
|
+
expect(docs).not.toBeNull();
|
|
87
|
+
expect(getLaunchableAppOrigin(docs!, { environment: 'production' })).toBe(
|
|
88
|
+
'https://docs.tuturuuu.com'
|
|
89
|
+
);
|
|
90
|
+
expect(getLaunchableAppOrigin(docs!, { environment: 'portless' })).toBe(
|
|
91
|
+
'https://docs.tuturuuu.localhost'
|
|
92
|
+
);
|
|
49
93
|
});
|
|
50
94
|
|
|
51
95
|
it('detects Portless dev from the current origin', () => {
|
package/src/launchable-apps.ts
CHANGED
|
@@ -2,13 +2,11 @@ import { toWorkspaceSlug } from './constants';
|
|
|
2
2
|
import { getTuturuuuPortlessAppOrigin } from './portless';
|
|
3
3
|
|
|
4
4
|
export const LAUNCHABLE_APP_CATEGORIES = [
|
|
5
|
-
'core',
|
|
6
5
|
'productivity',
|
|
7
|
-
'content',
|
|
8
6
|
'operations',
|
|
9
7
|
'learning',
|
|
10
|
-
'developer',
|
|
11
8
|
'ai',
|
|
9
|
+
'miscellaneous',
|
|
12
10
|
] as const;
|
|
13
11
|
|
|
14
12
|
export type LaunchableAppCategory = (typeof LAUNCHABLE_APP_CATEGORIES)[number];
|
|
@@ -53,7 +51,7 @@ export const LAUNCHABLE_APPS = [
|
|
|
53
51
|
{
|
|
54
52
|
aliases: ['Tuturuuu', 'Dashboard', 'Workspace'],
|
|
55
53
|
appRoot: 'apps/web',
|
|
56
|
-
category: '
|
|
54
|
+
category: 'productivity',
|
|
57
55
|
defaultPath: '/',
|
|
58
56
|
localhostOrigin: 'http://localhost:7803',
|
|
59
57
|
packageName: '@tuturuuu/web',
|
|
@@ -63,18 +61,6 @@ export const LAUNCHABLE_APPS = [
|
|
|
63
61
|
title: 'Platform',
|
|
64
62
|
workspacePathResolver: workspaceRootPath,
|
|
65
63
|
},
|
|
66
|
-
{
|
|
67
|
-
aliases: ['App Launcher', 'Gateway', 'Apps Gateway'],
|
|
68
|
-
appRoot: 'apps/apps',
|
|
69
|
-
category: 'core',
|
|
70
|
-
defaultPath: '/',
|
|
71
|
-
localhostOrigin: 'http://localhost:7818',
|
|
72
|
-
packageName: '@tuturuuu/apps',
|
|
73
|
-
portlessApp: 'apps',
|
|
74
|
-
productionUrl: 'https://apps.tuturuuu.com',
|
|
75
|
-
slug: 'apps',
|
|
76
|
-
title: 'Apps',
|
|
77
|
-
},
|
|
78
64
|
{
|
|
79
65
|
aliases: ['Schedule', 'Events'],
|
|
80
66
|
appRoot: 'apps/calendar',
|
|
@@ -104,7 +90,7 @@ export const LAUNCHABLE_APPS = [
|
|
|
104
90
|
{
|
|
105
91
|
aliases: ['Content', 'CMS'],
|
|
106
92
|
appRoot: 'apps/cms',
|
|
107
|
-
category: '
|
|
93
|
+
category: 'operations',
|
|
108
94
|
defaultPath: '/personal',
|
|
109
95
|
localhostOrigin: 'http://localhost:7811',
|
|
110
96
|
packageName: '@tuturuuu/cms',
|
|
@@ -128,15 +114,27 @@ export const LAUNCHABLE_APPS = [
|
|
|
128
114
|
workspacePathResolver: workspaceRootPath,
|
|
129
115
|
},
|
|
130
116
|
{
|
|
131
|
-
aliases: ['
|
|
132
|
-
appRoot: 'apps/
|
|
133
|
-
category: '
|
|
117
|
+
aliases: ['App Launcher', 'Gateway', 'Apps Gateway'],
|
|
118
|
+
appRoot: 'apps/apps',
|
|
119
|
+
category: 'miscellaneous',
|
|
134
120
|
defaultPath: '/',
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
121
|
+
localhostOrigin: 'http://localhost:7818',
|
|
122
|
+
packageName: '@tuturuuu/apps',
|
|
123
|
+
portlessApp: 'apps',
|
|
124
|
+
productionUrl: 'https://apps.tuturuuu.com',
|
|
125
|
+
slug: 'apps',
|
|
126
|
+
title: 'Apps',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
aliases: ['Documentation', 'Guides', 'Reference'],
|
|
130
|
+
appRoot: 'apps/docs',
|
|
131
|
+
category: 'miscellaneous',
|
|
132
|
+
defaultPath: '/',
|
|
133
|
+
packageName: 'apps/docs',
|
|
134
|
+
portlessApp: 'docs',
|
|
135
|
+
productionUrl: 'https://docs.tuturuuu.com',
|
|
136
|
+
slug: 'docs',
|
|
137
|
+
title: 'Docs',
|
|
140
138
|
},
|
|
141
139
|
{
|
|
142
140
|
aliases: ['Money', 'Wallets', 'Invoices'],
|
|
@@ -251,21 +249,10 @@ export const LAUNCHABLE_APPS = [
|
|
|
251
249
|
slug: 'nova',
|
|
252
250
|
title: 'Nova',
|
|
253
251
|
},
|
|
254
|
-
{
|
|
255
|
-
aliases: ['Labs', 'Sandbox'],
|
|
256
|
-
appRoot: 'apps/playground',
|
|
257
|
-
category: 'developer',
|
|
258
|
-
defaultPath: '/',
|
|
259
|
-
packageName: '@tuturuuu/playground',
|
|
260
|
-
portlessApp: 'playground',
|
|
261
|
-
productionUrl: 'https://playground.tuturuuu.com',
|
|
262
|
-
slug: 'playground',
|
|
263
|
-
title: 'Playground',
|
|
264
|
-
},
|
|
265
252
|
{
|
|
266
253
|
aliases: ['QR Code', 'Generator', 'Password', 'Random', 'UUID', 'Token'],
|
|
267
254
|
appRoot: 'apps/tools',
|
|
268
|
-
category: '
|
|
255
|
+
category: 'miscellaneous',
|
|
269
256
|
defaultPath: '/',
|
|
270
257
|
localhostOrigin: 'http://localhost:7825',
|
|
271
258
|
packageName: '@tuturuuu/tools',
|
|
@@ -277,7 +264,7 @@ export const LAUNCHABLE_APPS = [
|
|
|
277
264
|
{
|
|
278
265
|
aliases: ['Review', 'Flashcards'],
|
|
279
266
|
appRoot: 'apps/rewise',
|
|
280
|
-
category: '
|
|
267
|
+
category: 'ai',
|
|
281
268
|
defaultPath: '/',
|
|
282
269
|
localhostOrigin: 'http://localhost:7804',
|
|
283
270
|
packageName: '@tuturuuu/rewise',
|
|
@@ -289,7 +276,7 @@ export const LAUNCHABLE_APPS = [
|
|
|
289
276
|
{
|
|
290
277
|
aliases: ['Links', 'URL Shortener'],
|
|
291
278
|
appRoot: 'apps/shortener',
|
|
292
|
-
category: '
|
|
279
|
+
category: 'miscellaneous',
|
|
293
280
|
defaultPath: '/',
|
|
294
281
|
packageName: '@tuturuuu/shortener',
|
|
295
282
|
portlessApp: 'shortener',
|
|
@@ -323,6 +310,32 @@ export const LAUNCHABLE_APPS = [
|
|
|
323
310
|
title: 'Teach',
|
|
324
311
|
workspacePathResolver: workspaceRootPath,
|
|
325
312
|
},
|
|
313
|
+
{
|
|
314
|
+
aliases: ['Billing', 'Subscriptions', 'Payments'],
|
|
315
|
+
appRoot: 'apps/pay',
|
|
316
|
+
category: 'operations',
|
|
317
|
+
defaultPath: '/personal',
|
|
318
|
+
localhostOrigin: 'http://localhost:7826',
|
|
319
|
+
packageName: '@tuturuuu/pay',
|
|
320
|
+
portlessApp: 'pay',
|
|
321
|
+
productionUrl: 'https://pay.tuturuuu.com',
|
|
322
|
+
slug: 'pay',
|
|
323
|
+
title: 'Pay',
|
|
324
|
+
workspacePathResolver: workspaceRootPath,
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
aliases: ['CRM', 'Users', 'Members', 'Customers', 'Leads'],
|
|
328
|
+
appRoot: 'apps/contacts',
|
|
329
|
+
category: 'operations',
|
|
330
|
+
defaultPath: '/personal',
|
|
331
|
+
localhostOrigin: 'http://localhost:7827',
|
|
332
|
+
packageName: '@tuturuuu/contacts',
|
|
333
|
+
portlessApp: 'contacts',
|
|
334
|
+
productionUrl: 'https://contacts.tuturuuu.com',
|
|
335
|
+
slug: 'contacts',
|
|
336
|
+
title: 'Contacts',
|
|
337
|
+
workspacePathResolver: workspaceRootPath,
|
|
338
|
+
},
|
|
326
339
|
{
|
|
327
340
|
aliases: ['Time Tracking', 'Timer'],
|
|
328
341
|
appRoot: 'apps/track',
|
|
@@ -7,10 +7,10 @@ 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.21.0'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
-
it('
|
|
13
|
+
it('preserves the source timestamp in builtAt and derives a short hash', () => {
|
|
14
14
|
expect(
|
|
15
15
|
normalizePlatformBuildMetadata({
|
|
16
16
|
builtAt: '2026-05-27T10:00:00.000Z',
|
package/src/platform-release.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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.21.0'; // x-release-please-version
|
|
4
4
|
|
|
5
5
|
export type PlatformBuildMetadataInput = {
|
|
6
|
+
/** ISO timestamp of the source commit used for the build, not deployment time. */
|
|
6
7
|
builtAt?: string | null;
|
|
7
8
|
commitHash?: string | null;
|
|
8
9
|
commitMessage?: string | null;
|
|
@@ -14,6 +15,7 @@ export type PlatformBuildMetadataInput = {
|
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export type PlatformBuildMetadata = {
|
|
18
|
+
/** ISO timestamp of the source commit used for the build, not deployment time. */
|
|
17
19
|
builtAt: string;
|
|
18
20
|
commitHash: string;
|
|
19
21
|
commitMessage: string;
|
package/src/portless.ts
CHANGED
|
@@ -10,6 +10,8 @@ export const TUTURUUU_PORTLESS_APP_ORIGINS = {
|
|
|
10
10
|
calendar: `https://calendar.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
11
11
|
chat: `https://chat.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
12
12
|
cms: `https://cms.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
13
|
+
contacts: `https://contacts.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
14
|
+
docs: `https://docs.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
13
15
|
drive: `https://drive.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
14
16
|
external: `https://external.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
15
17
|
finance: `https://finance.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
@@ -22,6 +24,7 @@ export const TUTURUUU_PORTLESS_APP_ORIGINS = {
|
|
|
22
24
|
meet: `https://meet.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
23
25
|
mind: `https://mind.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
24
26
|
nova: `https://nova.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
27
|
+
pay: `https://pay.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
25
28
|
platform: TUTURUUU_PORTLESS_ROOT_ORIGIN,
|
|
26
29
|
playground: `https://playground.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
27
30
|
qr: `https://qr.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getHostOnlyCookieOptions,
|
|
4
|
+
getSharedAndHostOnlyCookieDeleteOptions,
|
|
5
|
+
getTuturuuuSharedCookieOptions,
|
|
6
|
+
resolveTuturuuuSharedCookieDomain,
|
|
7
|
+
} from './shared-cookie';
|
|
8
|
+
|
|
9
|
+
describe('shared Tuturuuu cookie helpers', () => {
|
|
10
|
+
it('resolves production root and subdomains to the parent domain', () => {
|
|
11
|
+
expect(
|
|
12
|
+
resolveTuturuuuSharedCookieDomain('https://tuturuuu.com')?.cookieDomain
|
|
13
|
+
).toBe('.tuturuuu.com');
|
|
14
|
+
expect(
|
|
15
|
+
resolveTuturuuuSharedCookieDomain('https://tasks.tuturuuu.com')
|
|
16
|
+
).toMatchObject({
|
|
17
|
+
cookieDomain: '.tuturuuu.com',
|
|
18
|
+
secure: true,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('resolves local first-party subdomains without Secure cookies', () => {
|
|
23
|
+
expect(
|
|
24
|
+
resolveTuturuuuSharedCookieDomain('https://tasks.tuturuuu.localhost')
|
|
25
|
+
).toMatchObject({
|
|
26
|
+
cookieDomain: '.tuturuuu.localhost',
|
|
27
|
+
secure: false,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('uses forwarded host headers for server requests', () => {
|
|
32
|
+
const request = new Request('http://internal.localhost/api', {
|
|
33
|
+
headers: {
|
|
34
|
+
'x-forwarded-host': 'calendar.tuturuuu.com',
|
|
35
|
+
'x-forwarded-proto': 'https',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(resolveTuturuuuSharedCookieDomain(request)).toMatchObject({
|
|
40
|
+
cookieDomain: '.tuturuuu.com',
|
|
41
|
+
secure: true,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not share cookies on preview, localhost, or custom domains', () => {
|
|
46
|
+
expect(
|
|
47
|
+
resolveTuturuuuSharedCookieDomain('https://tuturuuu-git-main.vercel.app')
|
|
48
|
+
).toBe(null);
|
|
49
|
+
expect(resolveTuturuuuSharedCookieDomain('http://localhost:7803')).toBe(
|
|
50
|
+
null
|
|
51
|
+
);
|
|
52
|
+
expect(resolveTuturuuuSharedCookieDomain('https://nova.ai.vn')).toBe(null);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('applies shared options only when the current host is first-party', () => {
|
|
56
|
+
expect(
|
|
57
|
+
getTuturuuuSharedCookieOptions(
|
|
58
|
+
{ maxAge: 60, path: '/', sameSite: 'lax' as const },
|
|
59
|
+
'https://finance.tuturuuu.com'
|
|
60
|
+
)
|
|
61
|
+
).toEqual({
|
|
62
|
+
domain: '.tuturuuu.com',
|
|
63
|
+
maxAge: 60,
|
|
64
|
+
path: '/',
|
|
65
|
+
sameSite: 'lax',
|
|
66
|
+
secure: true,
|
|
67
|
+
});
|
|
68
|
+
expect(
|
|
69
|
+
getTuturuuuSharedCookieOptions(
|
|
70
|
+
{ maxAge: 60, path: '/', sameSite: 'lax' as const },
|
|
71
|
+
'https://rewise.me'
|
|
72
|
+
)
|
|
73
|
+
).toEqual({
|
|
74
|
+
maxAge: 60,
|
|
75
|
+
path: '/',
|
|
76
|
+
sameSite: 'lax',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns host-only and shared delete options when a shared domain exists', () => {
|
|
81
|
+
expect(
|
|
82
|
+
getSharedAndHostOnlyCookieDeleteOptions(
|
|
83
|
+
{ path: '/', sameSite: 'lax' as const },
|
|
84
|
+
'https://drive.tuturuuu.com'
|
|
85
|
+
)
|
|
86
|
+
).toEqual([
|
|
87
|
+
{
|
|
88
|
+
maxAge: 0,
|
|
89
|
+
path: '/',
|
|
90
|
+
sameSite: 'lax',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
domain: '.tuturuuu.com',
|
|
94
|
+
maxAge: 0,
|
|
95
|
+
path: '/',
|
|
96
|
+
sameSite: 'lax',
|
|
97
|
+
secure: true,
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('strips domains from host-only options', () => {
|
|
103
|
+
expect(
|
|
104
|
+
getHostOnlyCookieOptions({
|
|
105
|
+
domain: '.tuturuuu.com',
|
|
106
|
+
maxAge: 0,
|
|
107
|
+
path: '/',
|
|
108
|
+
})
|
|
109
|
+
).toEqual({
|
|
110
|
+
maxAge: 0,
|
|
111
|
+
path: '/',
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
export interface TuturuuuSharedCookieDomain {
|
|
2
|
+
cookieDomain: '.tuturuuu.com' | '.tuturuuu.localhost';
|
|
3
|
+
hostname: 'tuturuuu.com' | 'tuturuuu.localhost';
|
|
4
|
+
secure: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type CookieOptionsLike = {
|
|
8
|
+
domain?: string;
|
|
9
|
+
expires?: Date;
|
|
10
|
+
maxAge?: number;
|
|
11
|
+
path?: string;
|
|
12
|
+
sameSite?: boolean | 'lax' | 'none' | 'strict';
|
|
13
|
+
secure?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type HeaderReader =
|
|
17
|
+
| Headers
|
|
18
|
+
| Pick<Headers, 'get'>
|
|
19
|
+
| Record<string, string | null | undefined>;
|
|
20
|
+
|
|
21
|
+
type CookieSource =
|
|
22
|
+
| URL
|
|
23
|
+
| string
|
|
24
|
+
| {
|
|
25
|
+
headers?: HeaderReader;
|
|
26
|
+
url?: string;
|
|
27
|
+
}
|
|
28
|
+
| null
|
|
29
|
+
| undefined;
|
|
30
|
+
|
|
31
|
+
export const TUTURUUU_SHARED_COOKIE_DOMAINS = [
|
|
32
|
+
{
|
|
33
|
+
cookieDomain: '.tuturuuu.com',
|
|
34
|
+
hostname: 'tuturuuu.com',
|
|
35
|
+
secure: true,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
cookieDomain: '.tuturuuu.localhost',
|
|
39
|
+
hostname: 'tuturuuu.localhost',
|
|
40
|
+
secure: false,
|
|
41
|
+
},
|
|
42
|
+
] as const satisfies readonly TuturuuuSharedCookieDomain[];
|
|
43
|
+
|
|
44
|
+
const ENV_APP_URL_KEYS = [
|
|
45
|
+
'PORTLESS_URL',
|
|
46
|
+
'NEXT_PUBLIC_APP_URL',
|
|
47
|
+
'NEXT_PUBLIC_WEB_APP_URL',
|
|
48
|
+
'WEB_APP_URL',
|
|
49
|
+
'VERCEL_URL',
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
function normalizeUrlLike(value: string | URL | null | undefined) {
|
|
53
|
+
if (!value) return null;
|
|
54
|
+
|
|
55
|
+
if (value instanceof URL) return value;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
return new URL(value);
|
|
59
|
+
} catch {
|
|
60
|
+
try {
|
|
61
|
+
return new URL(`https://${value}`);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readHeader(headers: HeaderReader | undefined, name: string) {
|
|
69
|
+
if (!headers) return null;
|
|
70
|
+
|
|
71
|
+
if ('get' in headers && typeof headers.get === 'function') {
|
|
72
|
+
return headers.get(name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lowerName = name.toLowerCase();
|
|
76
|
+
const record = headers as Record<string, string | null | undefined>;
|
|
77
|
+
|
|
78
|
+
return record[name] ?? record[lowerName] ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractForwardedValue(value: string | null | undefined) {
|
|
82
|
+
return value
|
|
83
|
+
?.split(',')
|
|
84
|
+
.map((entry) => entry.trim())
|
|
85
|
+
.find(Boolean);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function configuredAppUrl() {
|
|
89
|
+
if (typeof window !== 'undefined' && window.location?.href) {
|
|
90
|
+
return window.location.href;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const key of ENV_APP_URL_KEYS) {
|
|
94
|
+
const value = process.env[key];
|
|
95
|
+
if (value) return value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveUrlFromRequestLike(
|
|
102
|
+
source: Extract<CookieSource, { headers?: HeaderReader; url?: string }>
|
|
103
|
+
) {
|
|
104
|
+
const forwardedHost =
|
|
105
|
+
extractForwardedValue(readHeader(source.headers, 'x-forwarded-host')) ??
|
|
106
|
+
extractForwardedValue(readHeader(source.headers, 'host'));
|
|
107
|
+
|
|
108
|
+
if (forwardedHost && !/[\r\n]/u.test(forwardedHost)) {
|
|
109
|
+
const forwardedProto = extractForwardedValue(
|
|
110
|
+
readHeader(source.headers, 'x-forwarded-proto')
|
|
111
|
+
);
|
|
112
|
+
const fallbackProtocol = normalizeUrlLike(source.url)?.protocol ?? 'https:';
|
|
113
|
+
const protocol =
|
|
114
|
+
forwardedProto?.replace(/:$/u, '') === 'http'
|
|
115
|
+
? 'http:'
|
|
116
|
+
: fallbackProtocol === 'http:'
|
|
117
|
+
? 'http:'
|
|
118
|
+
: 'https:';
|
|
119
|
+
|
|
120
|
+
return normalizeUrlLike(`${protocol}//${forwardedHost}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return normalizeUrlLike(source.url);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveCookieUrl(source: CookieSource) {
|
|
127
|
+
if (!source) {
|
|
128
|
+
return normalizeUrlLike(configuredAppUrl());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (typeof source === 'string' || source instanceof URL) {
|
|
132
|
+
return normalizeUrlLike(source);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return resolveUrlFromRequestLike(source);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function domainForUrl(url: string | URL | null | undefined) {
|
|
139
|
+
const parsedUrl = normalizeUrlLike(url);
|
|
140
|
+
const hostname = parsedUrl?.hostname.toLowerCase();
|
|
141
|
+
|
|
142
|
+
if (!hostname) return null;
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
TUTURUUU_SHARED_COOKIE_DOMAINS.find(
|
|
146
|
+
(entry) =>
|
|
147
|
+
hostname === entry.hostname || hostname.endsWith(`.${entry.hostname}`)
|
|
148
|
+
) ?? null
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isPortlessBackendUrl(sourceUrl: URL | null) {
|
|
153
|
+
const portlessUrl = normalizeUrlLike(process.env.PORTLESS_URL);
|
|
154
|
+
const expectedPort = process.env.PORT;
|
|
155
|
+
|
|
156
|
+
if (!sourceUrl || !portlessUrl || !expectedPort) return false;
|
|
157
|
+
if (!domainForUrl(portlessUrl)) return false;
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
(sourceUrl.protocol === 'http:' || sourceUrl.protocol === 'https:') &&
|
|
161
|
+
(sourceUrl.hostname === '127.0.0.1' ||
|
|
162
|
+
sourceUrl.hostname === 'localhost') &&
|
|
163
|
+
sourceUrl.port === expectedPort
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function resolveTuturuuuSharedCookieDomain(source?: CookieSource) {
|
|
168
|
+
const sourceUrl = resolveCookieUrl(source);
|
|
169
|
+
const sourceDomain = domainForUrl(sourceUrl);
|
|
170
|
+
|
|
171
|
+
if (sourceDomain) return sourceDomain;
|
|
172
|
+
|
|
173
|
+
if (isPortlessBackendUrl(sourceUrl)) {
|
|
174
|
+
return domainForUrl(process.env.PORTLESS_URL);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function getTuturuuuSharedCookieOptions<
|
|
181
|
+
TOptions extends CookieOptionsLike,
|
|
182
|
+
>(options: TOptions, source?: CookieSource): TOptions {
|
|
183
|
+
const sharedDomain = resolveTuturuuuSharedCookieDomain(source);
|
|
184
|
+
const nextOptions = {
|
|
185
|
+
...options,
|
|
186
|
+
path: options.path ?? '/',
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
if (!sharedDomain) return nextOptions;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
...nextOptions,
|
|
193
|
+
domain: sharedDomain.cookieDomain,
|
|
194
|
+
secure: sharedDomain.secure,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function getTuturuuuBrowserSharedCookieOptions<
|
|
199
|
+
TOptions extends CookieOptionsLike,
|
|
200
|
+
>(options: TOptions): TOptions {
|
|
201
|
+
return getTuturuuuSharedCookieOptions(options);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function getHostOnlyCookieOptions<TOptions extends CookieOptionsLike>(
|
|
205
|
+
options: TOptions
|
|
206
|
+
): TOptions {
|
|
207
|
+
const { domain: _domain, ...hostOnlyOptions } = options;
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
...hostOnlyOptions,
|
|
211
|
+
path: options.path ?? '/',
|
|
212
|
+
} as TOptions;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function getSharedAndHostOnlyCookieDeleteOptions<
|
|
216
|
+
TOptions extends CookieOptionsLike,
|
|
217
|
+
>(options: TOptions, source?: CookieSource): TOptions[] {
|
|
218
|
+
const hostOnlyOptions = getHostOnlyCookieOptions({
|
|
219
|
+
...options,
|
|
220
|
+
maxAge: 0,
|
|
221
|
+
path: options.path ?? '/',
|
|
222
|
+
});
|
|
223
|
+
const sharedOptions = getTuturuuuSharedCookieOptions(
|
|
224
|
+
{
|
|
225
|
+
...options,
|
|
226
|
+
maxAge: 0,
|
|
227
|
+
path: options.path ?? '/',
|
|
228
|
+
},
|
|
229
|
+
source
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (!sharedOptions.domain) return [hostOnlyOptions];
|
|
233
|
+
|
|
234
|
+
return [hostOnlyOptions, sharedOptions];
|
|
235
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const TOPIC_ANNOUNCEMENTS_SECRET = 'ENABLE_TOPIC_ANNOUNCEMENTS';
|
package/src/user-helper.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import type { TypedSupabaseClient } from '@tuturuuu/supabase/next/client';
|
|
2
|
-
import {
|
|
3
|
-
createAdminClient,
|
|
4
|
-
createClient,
|
|
5
|
-
} from '@tuturuuu/supabase/next/server';
|
|
2
|
+
import { createClient } from '@tuturuuu/supabase/next/server';
|
|
6
3
|
import type { User, UserPrivateDetails } from '@tuturuuu/types';
|
|
7
4
|
import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
|
|
8
|
-
|
|
9
|
-
import { resolveWorkspaceId } from './constants';
|
|
10
|
-
import { verifyWorkspaceMembershipType } from './workspace-helper';
|
|
5
|
+
import { getWorkspaceUserLinkForUser } from './workspace-user-link';
|
|
11
6
|
|
|
12
7
|
async function resolveCurrentUserId(
|
|
13
8
|
supabase: TypedSupabaseClient
|
|
@@ -71,98 +66,14 @@ export async function getCurrentWorkspaceUser(
|
|
|
71
66
|
wsId: string,
|
|
72
67
|
options: GetCurrentWorkspaceUserOptions = {}
|
|
73
68
|
): Promise<WorkspaceUserLink | null> {
|
|
74
|
-
const { autoRepair = true } = options;
|
|
75
|
-
|
|
76
69
|
const supabase = await createClient();
|
|
77
70
|
const userId = await resolveCurrentUserId(supabase as TypedSupabaseClient);
|
|
78
71
|
if (!userId) return null;
|
|
79
72
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
.from('workspace_user_linked_users')
|
|
85
|
-
.select(
|
|
86
|
-
'platform_user_id, virtual_user_id, ws_id, created_at, workspace_users!virtual_user_id(*)'
|
|
87
|
-
)
|
|
88
|
-
.eq('platform_user_id', userId)
|
|
89
|
-
.eq('ws_id', resolvedWsId)
|
|
90
|
-
.limit(1)
|
|
91
|
-
.maybeSingle();
|
|
92
|
-
|
|
93
|
-
// If found, return it
|
|
94
|
-
if (workspaceUser) {
|
|
95
|
-
const linkedData = workspaceUser.workspace_users;
|
|
96
|
-
return {
|
|
97
|
-
platform_user_id: workspaceUser.platform_user_id,
|
|
98
|
-
virtual_user_id: workspaceUser.virtual_user_id,
|
|
99
|
-
ws_id: workspaceUser.ws_id,
|
|
100
|
-
created_at: workspaceUser.created_at,
|
|
101
|
-
...(linkedData ? { workspace_users: linkedData as WorkspaceUser } : {}),
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// If not found and auto-repair is disabled, return null
|
|
106
|
-
if (!autoRepair) return null;
|
|
107
|
-
|
|
108
|
-
const membership = await verifyWorkspaceMembershipType({
|
|
109
|
-
wsId: resolvedWsId,
|
|
110
|
-
userId,
|
|
111
|
-
supabase,
|
|
112
|
-
requiredType: 'MEMBER',
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
if (!membership.ok) return null;
|
|
116
|
-
|
|
117
|
-
// Try to repair the missing link using the RPC function
|
|
118
|
-
try {
|
|
119
|
-
const sbAdmin = await createAdminClient();
|
|
120
|
-
// Note: ensure_workspace_user_link is defined in migration 20260112060000
|
|
121
|
-
// Using type assertion since RPC types are generated after migration is applied
|
|
122
|
-
// IMPORTANT: Must use .bind() to preserve the Supabase client's `this` context
|
|
123
|
-
const rpc = sbAdmin.rpc.bind(sbAdmin) as unknown as (
|
|
124
|
-
fn: string,
|
|
125
|
-
args: Record<string, unknown>
|
|
126
|
-
) => Promise<{ error: Error | null }>;
|
|
127
|
-
const { error: repairError } = await rpc('ensure_workspace_user_link', {
|
|
128
|
-
target_user_id: userId,
|
|
129
|
-
target_ws_id: resolvedWsId,
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
if (repairError) {
|
|
133
|
-
console.error(
|
|
134
|
-
'[getCurrentWorkspaceUser] Failed to auto-repair workspace user link:',
|
|
135
|
-
repairError
|
|
136
|
-
);
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Fetch the newly created link
|
|
141
|
-
const { data: repairedUser } = await supabase
|
|
142
|
-
.from('workspace_user_linked_users')
|
|
143
|
-
.select(
|
|
144
|
-
'platform_user_id, virtual_user_id, ws_id, created_at, workspace_users!virtual_user_id(*)'
|
|
145
|
-
)
|
|
146
|
-
.eq('platform_user_id', userId)
|
|
147
|
-
.eq('ws_id', resolvedWsId)
|
|
148
|
-
.limit(1)
|
|
149
|
-
.maybeSingle();
|
|
150
|
-
|
|
151
|
-
if (repairedUser) {
|
|
152
|
-
const linkedData = repairedUser.workspace_users;
|
|
153
|
-
return {
|
|
154
|
-
platform_user_id: repairedUser.platform_user_id,
|
|
155
|
-
virtual_user_id: repairedUser.virtual_user_id,
|
|
156
|
-
ws_id: repairedUser.ws_id,
|
|
157
|
-
created_at: repairedUser.created_at,
|
|
158
|
-
...(linkedData ? { workspace_users: linkedData as WorkspaceUser } : {}),
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
} catch (err) {
|
|
162
|
-
console.error('[getCurrentWorkspaceUser] Error during auto-repair:', err);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return null;
|
|
73
|
+
// The link lookup + auto-repair lives in workspace-user-link so satellite apps
|
|
74
|
+
// can reuse it with an app-session actor: the internal-app-auth guard forbids
|
|
75
|
+
// registered apps from importing this module (it resolves actors via Supabase).
|
|
76
|
+
return getWorkspaceUserLinkForUser(wsId, userId, options);
|
|
166
77
|
}
|
|
167
78
|
|
|
168
79
|
export async function getCurrentUser() {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
createAdminClient: vi.fn(),
|
|
6
|
+
createClient: vi.fn(),
|
|
7
|
+
verifyWorkspaceMembershipType: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('@tuturuuu/supabase/next/server', () => ({
|
|
11
|
+
createAdminClient: mocks.createAdminClient,
|
|
12
|
+
createClient: mocks.createClient,
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('./workspace-helper', () => ({
|
|
15
|
+
verifyWorkspaceMembershipType: mocks.verifyWorkspaceMembershipType,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { getWorkspaceUserLinkForUser } from './workspace-user-link';
|
|
19
|
+
|
|
20
|
+
describe('getWorkspaceUserLinkForUser', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('uses an injected authorization client for satellite actors', async () => {
|
|
26
|
+
const link = {
|
|
27
|
+
created_at: '2026-07-13T00:00:00.000Z',
|
|
28
|
+
platform_user_id: 'platform-user-1',
|
|
29
|
+
virtual_user_id: 'virtual-user-1',
|
|
30
|
+
ws_id: 'workspace-1',
|
|
31
|
+
};
|
|
32
|
+
const maybeSingle = vi.fn().mockResolvedValue({ data: link, error: null });
|
|
33
|
+
const limit = vi.fn(() => ({ maybeSingle }));
|
|
34
|
+
const secondEq = vi.fn(() => ({ limit }));
|
|
35
|
+
const firstEq = vi.fn(() => ({ eq: secondEq }));
|
|
36
|
+
const select = vi.fn(() => ({ eq: firstEq }));
|
|
37
|
+
const authorizationClient = {
|
|
38
|
+
from: vi.fn(() => ({ select })),
|
|
39
|
+
} as unknown as TypedSupabaseClient;
|
|
40
|
+
|
|
41
|
+
await expect(
|
|
42
|
+
getWorkspaceUserLinkForUser('workspace-1', 'platform-user-1', {
|
|
43
|
+
authorizationClient,
|
|
44
|
+
})
|
|
45
|
+
).resolves.toEqual(link);
|
|
46
|
+
|
|
47
|
+
expect(authorizationClient.from).toHaveBeenCalledWith(
|
|
48
|
+
'workspace_user_linked_users'
|
|
49
|
+
);
|
|
50
|
+
expect(mocks.createClient).not.toHaveBeenCalled();
|
|
51
|
+
expect(mocks.createAdminClient).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAdminClient,
|
|
3
|
+
createClient,
|
|
4
|
+
} from '@tuturuuu/supabase/next/server';
|
|
5
|
+
import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
|
|
6
|
+
import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
|
|
7
|
+
import { resolveWorkspaceId } from './constants';
|
|
8
|
+
import { verifyWorkspaceMembershipType } from './workspace-helper';
|
|
9
|
+
|
|
10
|
+
export interface WorkspaceUserLink {
|
|
11
|
+
platform_user_id: string;
|
|
12
|
+
virtual_user_id: string;
|
|
13
|
+
ws_id: string;
|
|
14
|
+
created_at: string;
|
|
15
|
+
workspace_users?: WorkspaceUser;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const LINK_SELECT =
|
|
19
|
+
'platform_user_id, virtual_user_id, ws_id, created_at, workspace_users!virtual_user_id(*)';
|
|
20
|
+
|
|
21
|
+
type LinkRow = {
|
|
22
|
+
platform_user_id: string;
|
|
23
|
+
virtual_user_id: string;
|
|
24
|
+
ws_id: string;
|
|
25
|
+
created_at: string;
|
|
26
|
+
workspace_users?: unknown;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function toLink(row: LinkRow): WorkspaceUserLink {
|
|
30
|
+
const linkedData = row.workspace_users;
|
|
31
|
+
return {
|
|
32
|
+
platform_user_id: row.platform_user_id,
|
|
33
|
+
virtual_user_id: row.virtual_user_id,
|
|
34
|
+
ws_id: row.ws_id,
|
|
35
|
+
created_at: row.created_at,
|
|
36
|
+
...(linkedData ? { workspace_users: linkedData as WorkspaceUser } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolves the workspace-user link for an EXPLICIT platform user id.
|
|
42
|
+
*
|
|
43
|
+
* `getCurrentWorkspaceUser` (user-helper) resolves the actor from Supabase auth
|
|
44
|
+
* and then delegates here. Satellite apps must not do that: they resolve the
|
|
45
|
+
* actor from Tuturuuu app-session auth (`getSatelliteAppSessionUser`) and pass
|
|
46
|
+
* the id in, which is why this lives outside `user-helper` — the internal-app-auth
|
|
47
|
+
* guard forbids registered apps from importing that module.
|
|
48
|
+
*/
|
|
49
|
+
export async function getWorkspaceUserLinkForUser(
|
|
50
|
+
wsId: string,
|
|
51
|
+
userId: string,
|
|
52
|
+
options: {
|
|
53
|
+
authorizationClient?: TypedSupabaseClient;
|
|
54
|
+
autoRepair?: boolean;
|
|
55
|
+
} = {}
|
|
56
|
+
): Promise<WorkspaceUserLink | null> {
|
|
57
|
+
const { authorizationClient, autoRepair = true } = options;
|
|
58
|
+
if (!userId) return null;
|
|
59
|
+
|
|
60
|
+
const supabase = authorizationClient ?? (await createClient());
|
|
61
|
+
const resolvedWsId = resolveWorkspaceId(wsId);
|
|
62
|
+
|
|
63
|
+
const { data: workspaceUser } = await supabase
|
|
64
|
+
.from('workspace_user_linked_users')
|
|
65
|
+
.select(LINK_SELECT)
|
|
66
|
+
.eq('platform_user_id', userId)
|
|
67
|
+
.eq('ws_id', resolvedWsId)
|
|
68
|
+
.limit(1)
|
|
69
|
+
.maybeSingle();
|
|
70
|
+
|
|
71
|
+
if (workspaceUser) return toLink(workspaceUser as LinkRow);
|
|
72
|
+
|
|
73
|
+
if (!autoRepair) return null;
|
|
74
|
+
|
|
75
|
+
const membership = await verifyWorkspaceMembershipType({
|
|
76
|
+
wsId: resolvedWsId,
|
|
77
|
+
userId,
|
|
78
|
+
supabase,
|
|
79
|
+
requiredType: 'MEMBER',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!membership.ok) return null;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const sbAdmin = await createAdminClient();
|
|
86
|
+
// Note: ensure_workspace_user_link is defined in migration 20260112060000.
|
|
87
|
+
// IMPORTANT: must use .bind() to preserve the Supabase client's `this` context.
|
|
88
|
+
const rpc = sbAdmin.rpc.bind(sbAdmin) as unknown as (
|
|
89
|
+
fn: string,
|
|
90
|
+
args: Record<string, unknown>
|
|
91
|
+
) => Promise<{ error: Error | null }>;
|
|
92
|
+
const { error: repairError } = await rpc('ensure_workspace_user_link', {
|
|
93
|
+
target_user_id: userId,
|
|
94
|
+
target_ws_id: resolvedWsId,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (repairError) {
|
|
98
|
+
console.error(
|
|
99
|
+
'[getWorkspaceUserLinkForUser] Failed to auto-repair workspace user link:',
|
|
100
|
+
repairError
|
|
101
|
+
);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { data: repairedUser } = await supabase
|
|
106
|
+
.from('workspace_user_linked_users')
|
|
107
|
+
.select(LINK_SELECT)
|
|
108
|
+
.eq('platform_user_id', userId)
|
|
109
|
+
.eq('ws_id', resolvedWsId)
|
|
110
|
+
.limit(1)
|
|
111
|
+
.maybeSingle();
|
|
112
|
+
|
|
113
|
+
if (repairedUser) return toLink(repairedUser as LinkRow);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(
|
|
116
|
+
'[getWorkspaceUserLinkForUser] Error during auto-repair:',
|
|
117
|
+
err
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null;
|
|
122
|
+
}
|
package/src/yjs-helper.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { JSONContent } from '@tiptap/react';
|
|
2
|
-
import {
|
|
2
|
+
import type { Schema } from 'prosemirror-model';
|
|
3
3
|
import * as Y from 'yjs';
|
|
4
4
|
|
|
5
5
|
const PROSEMIRROR_FRAGMENT_NAME = 'prosemirror';
|
|
@@ -179,7 +179,7 @@ function validateJsonContent(
|
|
|
179
179
|
return jsonContent;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
return
|
|
182
|
+
return schema.nodeFromJSON(jsonContent).toJSON() as JSONContent;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
export function convertJsonContentToYjsState(
|