@tuturuuu/utils 0.13.0 → 0.15.1
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 +64 -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/next-config.test.ts +92 -0
- package/src/next-config.ts +23 -0
- 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/src/next-config.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { tryToParsePath } from 'next/dist/lib/try-to-parse-path';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
3
|
import {
|
|
3
4
|
createTuturuuuNextConfig,
|
|
@@ -40,6 +41,97 @@ describe('createTuturuuuNextConfig', () => {
|
|
|
40
41
|
});
|
|
41
42
|
});
|
|
42
43
|
|
|
44
|
+
it('protects responses from framing by default', async () => {
|
|
45
|
+
const config = createTuturuuuNextConfig();
|
|
46
|
+
|
|
47
|
+
await expect(config.headers?.()).resolves.toEqual([
|
|
48
|
+
{
|
|
49
|
+
source:
|
|
50
|
+
'/:path((?!api/v1/workspaces/[^/]+/external-projects/assets/[^/]+/webgl(?:/|$)).*)',
|
|
51
|
+
headers: [
|
|
52
|
+
{
|
|
53
|
+
key: 'Content-Security-Policy',
|
|
54
|
+
value: "frame-ancestors 'none'",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: 'X-Frame-Options',
|
|
58
|
+
value: 'DENY',
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('preserves app-specific response headers after shared security headers', async () => {
|
|
66
|
+
const config = createTuturuuuNextConfig({
|
|
67
|
+
async headers() {
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
source: '/login',
|
|
71
|
+
headers: [
|
|
72
|
+
{
|
|
73
|
+
key: 'Cache-Control',
|
|
74
|
+
value: 'public, max-age=0, must-revalidate',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
key: 'X-Robots-Tag',
|
|
78
|
+
value: 'noindex, nofollow',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const headers = await config.headers?.();
|
|
87
|
+
|
|
88
|
+
expect(headers).toHaveLength(2);
|
|
89
|
+
expect(headers?.[1]).toEqual({
|
|
90
|
+
source: '/login',
|
|
91
|
+
headers: [
|
|
92
|
+
{
|
|
93
|
+
key: 'Cache-Control',
|
|
94
|
+
value: 'public, max-age=0, must-revalidate',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
key: 'X-Robots-Tag',
|
|
98
|
+
value: 'noindex, nofollow',
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('exempts only the sandboxed CMS WebGL asset route from anti-framing headers', async () => {
|
|
105
|
+
const config = createTuturuuuNextConfig();
|
|
106
|
+
const [antiFramingRule] = (await config.headers?.()) ?? [];
|
|
107
|
+
const parsedPath = tryToParsePath(antiFramingRule?.source ?? '');
|
|
108
|
+
|
|
109
|
+
expect(parsedPath.error).toBeUndefined();
|
|
110
|
+
expect(parsedPath.regexStr).toBeDefined();
|
|
111
|
+
|
|
112
|
+
const routePattern = new RegExp(parsedPath.regexStr ?? '');
|
|
113
|
+
|
|
114
|
+
expect(routePattern.test('/')).toBe(true);
|
|
115
|
+
expect(routePattern.test('/login')).toBe(true);
|
|
116
|
+
expect(routePattern.test('/en/workspace')).toBe(true);
|
|
117
|
+
expect(routePattern.test('/api/billing/workspace/invoice')).toBe(true);
|
|
118
|
+
expect(
|
|
119
|
+
routePattern.test(
|
|
120
|
+
'/api/v1/workspaces/workspace-id/external-projects/assets/asset-id/webgl/index.html'
|
|
121
|
+
)
|
|
122
|
+
).toBe(false);
|
|
123
|
+
expect(
|
|
124
|
+
routePattern.test(
|
|
125
|
+
'/api/v1/workspaces/workspace-id/external-projects/assets/asset-id/webgl'
|
|
126
|
+
)
|
|
127
|
+
).toBe(false);
|
|
128
|
+
expect(
|
|
129
|
+
routePattern.test(
|
|
130
|
+
'/api/v1/workspaces/workspace-id/external-projects/assets/asset-id/webgl-preview'
|
|
131
|
+
)
|
|
132
|
+
).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
43
135
|
it('allows the exact worktree-prefixed Portless host for Next dev assets', () => {
|
|
44
136
|
const originalPortlessUrl = process.env.PORTLESS_URL;
|
|
45
137
|
|
package/src/next-config.ts
CHANGED
|
@@ -33,6 +33,20 @@ export const TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS = [
|
|
|
33
33
|
},
|
|
34
34
|
] satisfies NextImageRemotePattern[];
|
|
35
35
|
|
|
36
|
+
const TUTURUUU_ANTI_FRAMING_SOURCE =
|
|
37
|
+
'/:path((?!api/v1/workspaces/[^/]+/external-projects/assets/[^/]+/webgl(?:/|$)).*)';
|
|
38
|
+
|
|
39
|
+
const TUTURUUU_ANTI_FRAMING_HEADERS = [
|
|
40
|
+
{
|
|
41
|
+
key: 'Content-Security-Policy',
|
|
42
|
+
value: "frame-ancestors 'none'",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: 'X-Frame-Options',
|
|
46
|
+
value: 'DENY',
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
36
50
|
function mergeStringArrays(
|
|
37
51
|
first: readonly string[] | undefined,
|
|
38
52
|
second: readonly string[] | undefined
|
|
@@ -107,6 +121,15 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
|
|
|
107
121
|
experimentalConfig.optimizePackageImports
|
|
108
122
|
),
|
|
109
123
|
},
|
|
124
|
+
async headers() {
|
|
125
|
+
return [
|
|
126
|
+
{
|
|
127
|
+
source: TUTURUUU_ANTI_FRAMING_SOURCE,
|
|
128
|
+
headers: TUTURUUU_ANTI_FRAMING_HEADERS,
|
|
129
|
+
},
|
|
130
|
+
...((await config.headers?.()) ?? []),
|
|
131
|
+
];
|
|
132
|
+
},
|
|
110
133
|
};
|
|
111
134
|
}
|
|
112
135
|
|
|
@@ -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.22.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.22.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() {
|