@tuturuuu/utils 0.12.1 → 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.
@@ -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
+ }
@@ -142,6 +142,8 @@ export function transformTaskRecord(task: any): Task {
142
142
 
143
143
  return {
144
144
  ...task,
145
+ is_personal_external: task.is_personal_external ?? false,
146
+ is_personal_external_default: task.is_personal_external_default ?? false,
145
147
  assignees: transformAssignees(task.assignees),
146
148
  labels: normalizedLabels,
147
149
  projects: normalizedProjects,
@@ -0,0 +1 @@
1
+ export const TOPIC_ANNOUNCEMENTS_SECRET = 'ENABLE_TOPIC_ANNOUNCEMENTS';
@@ -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
- const resolvedWsId = resolveWorkspaceId(wsId);
81
-
82
- // First attempt to get the workspace user link
83
- const { data: workspaceUser } = await supabase
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 { Node as ProseMirrorNode, type Schema } from 'prosemirror-model';
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 ProseMirrorNode.fromJSON(schema, jsonContent).toJSON() as JSONContent;
182
+ return schema.nodeFromJSON(jsonContent).toJSON() as JSONContent;
183
183
  }
184
184
 
185
185
  export function convertJsonContentToYjsState(