@tuturuuu/utils 0.28.0 → 0.29.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/biome.json +1 -1
  3. package/package.json +17 -16
  4. package/src/__tests__/api-proxy-guard.test.ts +20 -27
  5. package/src/__tests__/app-url.test.ts +1 -0
  6. package/src/__tests__/workspace-principal-resolution.test.ts +34 -0
  7. package/src/abuse-protection/__tests__/reputation.test.ts +107 -0
  8. package/src/abuse-protection/reputation.ts +42 -3
  9. package/src/api-proxy-guard.ts +0 -15
  10. package/src/commercial-comparison-0.ts +294 -0
  11. package/src/commercial-comparison-1.ts +283 -0
  12. package/src/commercial-comparison-2.ts +262 -0
  13. package/src/commercial-comparison-types.ts +20 -0
  14. package/src/commercial-comparison.test.ts +32 -0
  15. package/src/commercial-comparison.ts +96 -0
  16. package/src/coordination-protocol.ts +61 -0
  17. package/src/coordination.test.ts +84 -0
  18. package/src/coordination.ts +57 -0
  19. package/src/feature-tier-policy.ts +26 -0
  20. package/src/internal-domains.ts +3 -0
  21. package/src/launchable-apps.ts +13 -0
  22. package/src/meet-creation-policy.test.ts +11 -0
  23. package/src/meet-creation-policy.ts +9 -3
  24. package/src/meet-duration.ts +71 -0
  25. package/src/meet-hosting.test.ts +58 -0
  26. package/src/meet-hosting.ts +19 -0
  27. package/src/meeting-invitations.test.ts +102 -0
  28. package/src/meeting-invitations.ts +81 -0
  29. package/src/onboarding.ts +1 -0
  30. package/src/platform-release.test.ts +1 -1
  31. package/src/platform-release.ts +1 -1
  32. package/src/portless.ts +1 -0
  33. package/src/task-helper/task-sort.test.ts +22 -3
  34. package/src/task-helper/task-sort.ts +33 -3
  35. package/src/workspace-helper.ts +3 -3
  36. package/src/workspace-limits.ts +1 -1
@@ -19,3 +19,14 @@ describe('online meeting creator domain', () => {
19
19
  expect(canCreateOnlineMeeting(email)).toBe(false);
20
20
  });
21
21
  });
22
+
23
+ it.each(['PLUS', 'PRO', 'ENTERPRISE'])(
24
+ 'allows verified external %s eligibility',
25
+ (tier) => {
26
+ expect(canCreateOnlineMeeting('host@example.test', tier)).toBe(true);
27
+ expect(canCreateOnlineMeeting('bad@@example.test', tier)).toBe(false);
28
+ }
29
+ );
30
+ it('does not unlock hosting for an unknown tier', () => {
31
+ expect(canCreateOnlineMeeting('host@example.test', 'UNKNOWN')).toBe(false);
32
+ });
@@ -1,6 +1,12 @@
1
- /** Apply only to an authenticated account email, never editable profile metadata. */
1
+ /** Use a current authenticated email and server-resolved personal subscription tier. */
2
2
  export function canCreateOnlineMeeting(
3
- email: string | null | undefined
3
+ email: string | null | undefined,
4
+ tier: string = 'FREE'
4
5
  ): boolean {
5
- return typeof email === 'string' && /^[^\s@]+@tuturuuu\.com$/iu.test(email);
6
+ if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+$/u.test(email))
7
+ return false;
8
+ return (
9
+ /^[^\s@]+@tuturuuu\.com$/iu.test(email) ||
10
+ ['PLUS', 'PRO', 'ENTERPRISE'].includes(tier)
11
+ );
6
12
  }
@@ -0,0 +1,71 @@
1
+ import 'server-only';
2
+ import { createAdminClient } from '@tuturuuu/supabase/next/server';
3
+ import type { WorkspaceProductTier } from '@tuturuuu/types';
4
+ import { extractTierFromSubscriptions } from './workspace-helper';
5
+ export class MeetDurationError extends Error {
6
+ readonly status = 503;
7
+ }
8
+
9
+ export function meetingDurationSeconds(tier: string) {
10
+ return (
11
+ (tier === 'PLUS' ? 10 : tier === 'PRO' || tier === 'ENTERPRISE' ? 24 : 2) *
12
+ 3600
13
+ );
14
+ }
15
+
16
+ /** Entitlements belong to the host's personal account, never the joining guest. */
17
+ export async function getHostMeetingTier(
18
+ hostId: string
19
+ ): Promise<WorkspaceProductTier> {
20
+ const db = await createAdminClient({ noCookie: true });
21
+ const { data, error } = await db
22
+ .from('workspaces')
23
+ .select('id')
24
+ .eq('creator_id', hostId)
25
+ .eq('personal', true)
26
+ .or('deleted.is.null,deleted.eq.false')
27
+ .maybeSingle();
28
+ if (error) throw new MeetDurationError('Meeting entitlement lookup failed');
29
+ if (!data) return 'FREE';
30
+ const subscriptions = await db
31
+ .from('workspace_subscriptions')
32
+ .select('created_at, status, product_id')
33
+ .eq('ws_id', data.id)
34
+ .eq('status', 'active');
35
+ if (subscriptions.error)
36
+ throw new MeetDurationError('Meeting entitlement lookup failed');
37
+ const ids = [
38
+ ...new Set(
39
+ subscriptions.data
40
+ .map((subscription) => subscription.product_id)
41
+ .filter((id): id is string => !!id)
42
+ ),
43
+ ];
44
+ if (!ids.length) return 'FREE';
45
+ const products = await db
46
+ .schema('private')
47
+ .from('workspace_subscription_products')
48
+ .select('id, tier')
49
+ .in('id', ids);
50
+ if (products.error)
51
+ throw new MeetDurationError('Meeting entitlement lookup failed');
52
+ const tiers = new Map(
53
+ products.data.map((product) => [product.id, product.tier])
54
+ );
55
+ if (ids.some((id) => !tiers.get(id)))
56
+ throw new MeetDurationError('Meeting entitlement product unavailable');
57
+ const tier = extractTierFromSubscriptions(
58
+ subscriptions.data.map((subscription) => ({
59
+ ...subscription,
60
+ product_tier: subscription.product_id
61
+ ? (tiers.get(subscription.product_id) ?? null)
62
+ : null,
63
+ }))
64
+ );
65
+
66
+ return tier ?? 'FREE';
67
+ }
68
+
69
+ export async function getHostMeetingDurationSeconds(hostId: string) {
70
+ return meetingDurationSeconds(await getHostMeetingTier(hostId));
71
+ }
@@ -0,0 +1,58 @@
1
+ import { beforeEach, expect, it, vi } from 'vitest';
2
+
3
+ const tier = vi.hoisted(() => vi.fn());
4
+ vi.mock('server-only', () => ({}));
5
+ vi.mock('./meet-duration', () => ({ getHostMeetingTier: tier }));
6
+
7
+ import { canVerifiedAccountHostMeeting } from './meet-hosting';
8
+
9
+ beforeEach(() => {
10
+ vi.resetAllMocks();
11
+ tier.mockResolvedValue('FREE');
12
+ });
13
+ it.each(['PLUS', 'PRO', 'ENTERPRISE'])(
14
+ 'unlocks verified %s accounts',
15
+ async (value) => {
16
+ tier.mockResolvedValue(value);
17
+ expect(
18
+ await canVerifiedAccountHostMeeting('actor', {
19
+ email: 'host@example.com',
20
+ email_confirmed_at: '2026-01-01',
21
+ })
22
+ ).toBe(true);
23
+ expect(tier).toHaveBeenCalledWith('actor');
24
+ }
25
+ );
26
+ it('requires verification before considering a paid subscription', async () => {
27
+ tier.mockResolvedValue('PLUS');
28
+ expect(
29
+ await canVerifiedAccountHostMeeting('actor', { email: 'host@example.com' })
30
+ ).toBe(false);
31
+ expect(tier).not.toHaveBeenCalled();
32
+ });
33
+ it('preserves verified company hosting without paid entitlement', async () => {
34
+ expect(
35
+ await canVerifiedAccountHostMeeting('actor', {
36
+ email: 'host@tuturuuu.com',
37
+ email_confirmed_at: '2026-01-01',
38
+ })
39
+ ).toBe(true);
40
+ expect(tier).not.toHaveBeenCalled();
41
+ });
42
+ it('denies external Free accounts', async () => {
43
+ expect(
44
+ await canVerifiedAccountHostMeeting('actor', {
45
+ email: 'host@example.com',
46
+ email_confirmed_at: '2026-01-01',
47
+ })
48
+ ).toBe(false);
49
+ });
50
+ it('fails closed when entitlement cannot be resolved', async () => {
51
+ tier.mockRejectedValue(new Error('Unavailable'));
52
+ await expect(
53
+ canVerifiedAccountHostMeeting('actor', {
54
+ email: 'host@example.com',
55
+ email_confirmed_at: '2026-01-01',
56
+ })
57
+ ).rejects.toThrow('Unavailable');
58
+ });
@@ -0,0 +1,19 @@
1
+ import 'server-only';
2
+ import { canCreateOnlineMeeting } from './meet-creation-policy';
3
+ import { getHostMeetingTier } from './meet-duration';
4
+
5
+ /** Pass only the current auth.admin.getUserById result, never handoff/profile metadata. */
6
+ export async function canVerifiedAccountHostMeeting(
7
+ accountId: string,
8
+ identity:
9
+ | { email?: string | null; email_confirmed_at?: string | null }
10
+ | null
11
+ | undefined
12
+ ) {
13
+ if (!identity?.email_confirmed_at || !identity.email) return false;
14
+ if (canCreateOnlineMeeting(identity.email)) return true;
15
+ return canCreateOnlineMeeting(
16
+ identity.email,
17
+ await getHostMeetingTier(accountId)
18
+ );
19
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ MeetingInvitationInputSchema,
4
+ normalizeGoogleResponse,
5
+ normalizeMicrosoftResponse,
6
+ } from './meeting-invitations';
7
+
8
+ describe('meeting invitation contract', () => {
9
+ it('normalizes internal and external guests without accepting response spoofing', () => {
10
+ const invitation = MeetingInvitationInputSchema.parse({
11
+ guests: [
12
+ { email: 'NAME@TUTURUUU.COM', response: 'accepted' },
13
+ { email: 'friend@gmail.com', optional: true },
14
+ ],
15
+ timeZone: 'Asia/Ho_Chi_Minh',
16
+ });
17
+ expect(invitation.guests[0]).toEqual({ email: 'name@tuturuuu.com' });
18
+ expect(invitation.guests[1]?.optional).toBe(true);
19
+ });
20
+ it.each([
21
+ { guests: [], timeZone: 'UTC' },
22
+ { guests: [{ email: 'invalid' }], timeZone: 'UTC' },
23
+ { guests: [{ email: 'a@example.com' }], timeZone: 'fake/zone' },
24
+ {
25
+ guests: [{ email: 'a@example.com' }, { email: 'A@EXAMPLE.COM' }],
26
+ timeZone: 'UTC',
27
+ },
28
+ {
29
+ guests: Array.from({ length: 101 }, (_, i) => ({
30
+ email: `${i}@example.com`,
31
+ })),
32
+ timeZone: 'UTC',
33
+ },
34
+ ])('rejects invalid or ambiguous invitation input', (input) => {
35
+ expect(MeetingInvitationInputSchema.safeParse(input).success).toBe(false);
36
+ });
37
+ it.each(['accepted', 'declined', 'tentative', 'needsAction'])(
38
+ 'preserves Google %s',
39
+ (value) => {
40
+ expect(normalizeGoogleResponse(value)).toBe(value);
41
+ }
42
+ );
43
+ it.each([
44
+ ['accepted', 'accepted'],
45
+ ['declined', 'declined'],
46
+ ['tentativelyAccepted', 'tentative'],
47
+ ['notResponded', 'needsAction'],
48
+ ['none', 'needsAction'],
49
+ ['unexpected', 'needsAction'],
50
+ ])('normalizes Microsoft %s', (value, expected) => {
51
+ expect(normalizeMicrosoftResponse(value)).toBe(expected);
52
+ });
53
+ });
54
+
55
+ describe('untrusted invitation input boundaries', () => {
56
+ it.each([
57
+ null,
58
+ undefined,
59
+ 'accepted',
60
+ [],
61
+ { guests: [{ email: 'a@example.com', optional: 'yes' }], timeZone: 'UTC' },
62
+ {
63
+ guests: [{ email: 'a@example.com', name: 'a'.repeat(201) }],
64
+ timeZone: 'UTC',
65
+ },
66
+ {
67
+ guests: [{ email: 'a@example.com\r\nBcc: victim@example.com' }],
68
+ timeZone: 'UTC',
69
+ },
70
+ { guests: [{ email: 'a@example.com' }], timeZone: null },
71
+ ])('rejects malformed input without throwing from safeParse', (input) => {
72
+ expect(MeetingInvitationInputSchema.safeParse(input).success).toBe(false);
73
+ });
74
+
75
+ it('accepts the maximum guest count and strips organizer-controlled provider state', () => {
76
+ const result = MeetingInvitationInputSchema.parse({
77
+ guests: Array.from({ length: 100 }, (_, i) => ({
78
+ email: `guest${i}@example.com`,
79
+ self: true,
80
+ response: 'accepted',
81
+ })),
82
+ timeZone: 'UTC',
83
+ organizer: { email: 'spoof@example.com' },
84
+ cancelled: true,
85
+ });
86
+ expect(result.guests).toHaveLength(100);
87
+ expect(result).not.toHaveProperty('organizer');
88
+ expect(result).not.toHaveProperty('cancelled');
89
+ for (const guest of result.guests) {
90
+ expect(guest).not.toHaveProperty('self');
91
+ expect(guest).not.toHaveProperty('response');
92
+ }
93
+ });
94
+
95
+ it.each([null, undefined, {}, [], 0, 'ACCEPTED', '<script>'])(
96
+ 'treats unknown provider responses as unanswered',
97
+ (value) => {
98
+ expect(normalizeGoogleResponse(value)).toBe('needsAction');
99
+ expect(normalizeMicrosoftResponse(value)).toBe('needsAction');
100
+ }
101
+ );
102
+ });
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod';
2
+
3
+ export const MeetingResponseSchema = z.enum([
4
+ 'needsAction',
5
+ 'accepted',
6
+ 'declined',
7
+ 'tentative',
8
+ ]);
9
+ export type MeetingResponse = z.infer<typeof MeetingResponseSchema>;
10
+
11
+ export const MeetingGuestSchema = z.object({
12
+ email: z
13
+ .email()
14
+ .max(254)
15
+ .transform((value) => value.trim().toLowerCase()),
16
+ name: z.string().trim().max(200).optional(),
17
+ optional: z.boolean().optional(),
18
+ });
19
+
20
+ /** Guest responses are provider-owned, never accepted from organizer input. */
21
+ export const MeetingInvitationInputSchema = z
22
+ .object({
23
+ guests: z.array(MeetingGuestSchema).min(1).max(100),
24
+ timeZone: z
25
+ .string()
26
+ .max(100)
27
+ .refine((value) => {
28
+ try {
29
+ new Intl.DateTimeFormat('en', { timeZone: value });
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }, 'Invalid time zone'),
35
+ })
36
+ .superRefine((value, context) => {
37
+ const emails = new Set<string>();
38
+ value.guests.forEach((guest, index) => {
39
+ if (emails.has(guest.email))
40
+ context.addIssue({
41
+ code: 'custom',
42
+ message: 'Duplicate guest',
43
+ path: ['guests', index, 'email'],
44
+ });
45
+ emails.add(guest.email);
46
+ });
47
+ });
48
+ export type MeetingInvitationInput = z.infer<
49
+ typeof MeetingInvitationInputSchema
50
+ >;
51
+
52
+ export type MeetingAttendee = z.infer<typeof MeetingGuestSchema> & {
53
+ response: MeetingResponse;
54
+ self?: boolean;
55
+ };
56
+ export interface MeetingInvitationState {
57
+ organizer: { email: string; name?: string; self: boolean };
58
+ attendees: MeetingAttendee[];
59
+ timeZone?: string;
60
+ iCalUid?: string;
61
+ cancelled: boolean;
62
+ }
63
+
64
+ export function normalizeGoogleResponse(value: unknown): MeetingResponse {
65
+ const parsed = MeetingResponseSchema.safeParse(value);
66
+ return parsed.success ? parsed.data : 'needsAction';
67
+ }
68
+
69
+ export function normalizeMicrosoftResponse(value: unknown): MeetingResponse {
70
+ switch (value) {
71
+ case 'accepted':
72
+ case 'organizer':
73
+ return 'accepted';
74
+ case 'declined':
75
+ return 'declined';
76
+ case 'tentativelyAccepted':
77
+ return 'tentative';
78
+ default:
79
+ return 'needsAction';
80
+ }
81
+ }
package/src/onboarding.ts CHANGED
@@ -109,6 +109,7 @@ const MISSIONS = [
109
109
  'confirmation_required',
110
110
  ['store', 'preview', 'checkout'],
111
111
  ],
112
+ ['lettin', 'build', 'confirmation_required', ['world', 'write', 'publish']],
112
113
  ['learn', 'learn', 'reversible', ['courses', 'study', 'progress']],
113
114
  ['mail', 'focus', 'confirmation_required', ['inbox', 'compose', 'follow_up']],
114
115
  ['meet', 'collaborate', 'confirmation_required', ['room', 'invite', 'meet']],
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  describe('platform release metadata', () => {
9
9
  it('uses the centralized shared browser app version', () => {
10
- expect(TUTURUUU_PLATFORM_VERSION).toBe('0.46.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.53.0'); // x-release-please-version
11
11
  });
12
12
 
13
13
  it('preserves the source timestamp in builtAt and derives a short hash', () => {
@@ -1,6 +1,6 @@
1
1
  import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
2
2
 
3
- export const TUTURUUU_PLATFORM_VERSION = '0.46.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.53.0'; // x-release-please-version
4
4
 
5
5
  export type PlatformBuildMetadataInput = {
6
6
  /** ISO timestamp of the source commit used for the build, not deployment time. */
package/src/portless.ts CHANGED
@@ -24,6 +24,7 @@ export const TUTURUUU_PORTLESS_APP_ORIGINS = {
24
24
  infra: `https://infra.${TUTURUUU_PORTLESS_ROOT_HOST}`,
25
25
  inventory: `https://inventory.${TUTURUUU_PORTLESS_ROOT_HOST}`,
26
26
  learn: `https://learn.${TUTURUUU_PORTLESS_ROOT_HOST}`,
27
+ lettin: `https://lettin.${TUTURUUU_PORTLESS_ROOT_HOST}`,
27
28
  mail: `https://mail.${TUTURUUU_PORTLESS_ROOT_HOST}`,
28
29
  meet: `https://meet.${TUTURUUU_PORTLESS_ROOT_HOST}`,
29
30
  mind: `https://mind.${TUTURUUU_PORTLESS_ROOT_HOST}`,
@@ -22,7 +22,7 @@ describe('sortTasksByCriterion', () => {
22
22
  expect(parseTaskSortBy(null)).toBeUndefined();
23
23
  });
24
24
 
25
- it('sorts every priority direction with unset priorities last', () => {
25
+ it('sorts every priority direction with unset priorities first by default', () => {
26
26
  const tasks = [
27
27
  task('none'),
28
28
  task('high', { priority: 'high' }),
@@ -33,12 +33,31 @@ describe('sortTasksByCriterion', () => {
33
33
 
34
34
  expect(
35
35
  sortTasksByCriterion(tasks, 'priority-high').map(({ id }) => id)
36
- ).toEqual(['critical', 'high', 'normal', 'low', 'none']);
36
+ ).toEqual(['none', 'critical', 'high', 'normal', 'low']);
37
37
  expect(
38
38
  sortTasksByCriterion(tasks, 'priority-low').map(({ id }) => id)
39
- ).toEqual(['low', 'normal', 'high', 'critical', 'none']);
39
+ ).toEqual(['none', 'low', 'normal', 'high', 'critical']);
40
40
  });
41
41
 
42
+ it.each(['priority-high', 'priority-low'] as const)(
43
+ 'keeps null and undefined priorities last when requested: %s',
44
+ (sortBy) => {
45
+ const tasks = [
46
+ task('null', { priority: null }),
47
+ task('set', { priority: 'high' }),
48
+ task('undefined'),
49
+ ];
50
+ expect(
51
+ sortTasksByCriterion(tasks, sortBy, 'last').map((t) => t.id)
52
+ ).toEqual(['set', 'null', 'undefined']);
53
+ expect(sortTasksByCriterion(tasks, sortBy).map((t) => t.id)).toEqual([
54
+ 'null',
55
+ 'undefined',
56
+ 'set',
57
+ ]);
58
+ }
59
+ );
60
+
42
61
  it('keeps missing due dates and estimations last in both directions', () => {
43
62
  const tasks = [
44
63
  task('none'),
@@ -1,5 +1,14 @@
1
1
  import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
2
2
 
3
+ export const TASK_UNPRIORITIZED_POSITION_CONFIG_ID =
4
+ 'TASK_UNPRIORITIZED_POSITION';
5
+ export type UnprioritizedPosition = 'first' | 'last';
6
+ export function normalizeUnprioritizedPosition(
7
+ value: unknown
8
+ ): UnprioritizedPosition {
9
+ return value === 'last' ? 'last' : 'first';
10
+ }
11
+
3
12
  export type TaskSortBy =
4
13
  | 'name-asc'
5
14
  | 'name-desc'
@@ -78,11 +87,29 @@ function compareCreatedFallback(a: SortableTask, b: SortableTask) {
78
87
  return created || a.id.localeCompare(b.id);
79
88
  }
80
89
 
90
+ export function compareUnprioritizedPlacement(
91
+ a: TaskPriority | null | undefined,
92
+ b: TaskPriority | null | undefined,
93
+ position: UnprioritizedPosition = 'first'
94
+ ) {
95
+ if ((a == null) === (b == null)) return 0;
96
+ return (a == null ? -1 : 1) * (position === 'first' ? 1 : -1);
97
+ }
98
+
81
99
  export function compareTasksByCriterion(
82
100
  a: SortableTask,
83
101
  b: SortableTask,
84
- sortBy: TaskSortBy
102
+ sortBy: TaskSortBy,
103
+ unprioritizedPosition: UnprioritizedPosition = 'first'
85
104
  ) {
105
+ if (sortBy === 'priority-high' || sortBy === 'priority-low') {
106
+ const placement = compareUnprioritizedPlacement(
107
+ a.priority,
108
+ b.priority,
109
+ unprioritizedPosition
110
+ );
111
+ if (placement) return placement;
112
+ }
86
113
  let result = 0;
87
114
 
88
115
  switch (sortBy) {
@@ -155,8 +182,11 @@ export function compareTasksByCriterion(
155
182
 
156
183
  export function sortTasksByCriterion<T extends SortableTask>(
157
184
  tasks: readonly T[],
158
- sortBy: TaskSortBy | null | undefined
185
+ sortBy: TaskSortBy | null | undefined,
186
+ unprioritizedPosition: UnprioritizedPosition = 'first'
159
187
  ) {
160
188
  if (!sortBy) return [...tasks];
161
- return [...tasks].sort((a, b) => compareTasksByCriterion(a, b, sortBy));
189
+ return [...tasks].sort((a, b) =>
190
+ compareTasksByCriterion(a, b, sortBy, unprioritizedPosition)
191
+ );
162
192
  }
@@ -702,7 +702,6 @@ export async function resolveWorkspaceIdForPrincipal({
702
702
  if (wsId.trim().toLowerCase() !== PERSONAL_WORKSPACE_SLUG) {
703
703
  return normalizeWorkspaceId(wsId, authorizationClient);
704
704
  }
705
-
706
705
  const { data: creatorWorkspace, error: creatorWorkspaceError } =
707
706
  await authorizationClient
708
707
  .from('workspaces')
@@ -710,7 +709,6 @@ export async function resolveWorkspaceIdForPrincipal({
710
709
  .eq('personal', true)
711
710
  .eq('creator_id', principal.id)
712
711
  .maybeSingle();
713
-
714
712
  if (!creatorWorkspaceError && creatorWorkspace?.id) {
715
713
  return creatorWorkspace.id;
716
714
  }
@@ -725,7 +723,9 @@ export async function resolveWorkspaceIdForPrincipal({
725
723
  .maybeSingle();
726
724
 
727
725
  if (memberWorkspaceError || !memberWorkspace?.id) {
728
- throw new Error('Personal workspace not found');
726
+ throw creatorWorkspaceError || memberWorkspaceError
727
+ ? new WorkspaceResolutionError('Personal workspace lookup failed')
728
+ : new WorkspaceNotFoundError('Personal workspace not found');
729
729
  }
730
730
 
731
731
  return memberWorkspace.id;
@@ -55,7 +55,7 @@ export async function checkWorkspaceCreationLimit(
55
55
  canCreate: false,
56
56
  currentCount,
57
57
  limit: MAX_WORKSPACES_FOR_FREE_USERS,
58
- errorMessage: `You have reached the maximum limit of ${MAX_WORKSPACES_FOR_FREE_USERS} workspaces. Please upgrade to a paid plan or contact the Tuturuuu team for more information.`,
58
+ errorMessage: `You have reached the maximum limit of ${MAX_WORKSPACES_FOR_FREE_USERS} workspaces. Upgrading does not increase this account limit. Contact the Tuturuuu team if you need more workspaces.`,
59
59
  errorCode: WORKSPACE_LIMIT_ERROR_CODE,
60
60
  };
61
61
  }