@tuturuuu/utils 0.0.3 → 0.6.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 (186) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
@@ -0,0 +1,859 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { mockCreateAdminClient, mockCreateClient } = vi.hoisted(() => ({
4
+ mockCreateAdminClient: vi.fn(),
5
+ mockCreateClient: vi.fn(),
6
+ }));
7
+
8
+ vi.mock('@tuturuuu/supabase/next/server', () => ({
9
+ createAdminClient: mockCreateAdminClient,
10
+ createClient: mockCreateClient,
11
+ }));
12
+
13
+ import {
14
+ getPermissions,
15
+ getWorkspace,
16
+ getWorkspaces,
17
+ isPersonalWorkspace,
18
+ isWorkspaceUuidLiteral,
19
+ normalizeWorkspaceId,
20
+ } from '../workspace-helper';
21
+
22
+ describe('isWorkspaceUuidLiteral', () => {
23
+ it('accepts Postgres UUID literals that are not RFC-versioned UUIDs', () => {
24
+ expect(isWorkspaceUuidLiteral('00000000-0000-0000-0000-000000000003')).toBe(
25
+ true
26
+ );
27
+ expect(isWorkspaceUuidLiteral('11111111-1111-4111-8111-111111111111')).toBe(
28
+ true
29
+ );
30
+ expect(isWorkspaceUuidLiteral('workspace-slug')).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe('isPersonalWorkspace', () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ });
38
+
39
+ it('returns false for non-UUID literals without querying or logging', async () => {
40
+ const query = {
41
+ select: vi.fn(),
42
+ eq: vi.fn(),
43
+ maybeSingle: vi.fn(),
44
+ };
45
+ query.select.mockReturnValue(query);
46
+ query.eq.mockReturnValue(query);
47
+ query.maybeSingle.mockResolvedValue({
48
+ data: null,
49
+ error: {
50
+ code: '22P02',
51
+ message: 'invalid input syntax for type uuid',
52
+ },
53
+ });
54
+
55
+ const fromMock = vi.fn(() => query);
56
+ const errorSpy = vi
57
+ .spyOn(console, 'error')
58
+ .mockImplementation(() => undefined);
59
+
60
+ mockCreateClient.mockResolvedValue({
61
+ from: fromMock,
62
+ });
63
+
64
+ try {
65
+ for (const workspaceId of [
66
+ '[locale]',
67
+ '~',
68
+ 'personal',
69
+ 'workspace-slug',
70
+ ]) {
71
+ await expect(isPersonalWorkspace(workspaceId)).resolves.toBe(false);
72
+ }
73
+
74
+ expect(mockCreateClient).not.toHaveBeenCalled();
75
+ expect(fromMock).not.toHaveBeenCalled();
76
+ expect(errorSpy).not.toHaveBeenCalled();
77
+ } finally {
78
+ errorSpy.mockRestore();
79
+ }
80
+ });
81
+
82
+ it('queries the personal flag for valid workspace UUID literals', async () => {
83
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
84
+ const query = {
85
+ select: vi.fn(),
86
+ eq: vi.fn(),
87
+ maybeSingle: vi.fn(),
88
+ };
89
+ query.select.mockReturnValue(query);
90
+ query.eq.mockReturnValue(query);
91
+ query.maybeSingle.mockResolvedValue({
92
+ data: { personal: true },
93
+ error: null,
94
+ });
95
+
96
+ const fromMock = vi.fn((table: string) => {
97
+ if (table !== 'workspaces') {
98
+ throw new Error(`Unexpected table lookup: ${table}`);
99
+ }
100
+
101
+ return query;
102
+ });
103
+
104
+ mockCreateClient.mockResolvedValue({
105
+ from: fromMock,
106
+ });
107
+
108
+ await expect(isPersonalWorkspace(workspaceId)).resolves.toBe(true);
109
+
110
+ expect(mockCreateClient).toHaveBeenCalledTimes(1);
111
+ expect(fromMock).toHaveBeenCalledWith('workspaces');
112
+ expect(query.select).toHaveBeenCalledWith('personal');
113
+ expect(query.eq).toHaveBeenCalledWith('id', workspaceId);
114
+ expect(query.maybeSingle).toHaveBeenCalledTimes(1);
115
+ });
116
+ });
117
+
118
+ describe('workspace-helper tier lookup', () => {
119
+ const workspaceOneId = '11111111-1111-4111-8111-111111111111';
120
+ const workspaceTwoId = '22222222-2222-4222-8222-222222222222';
121
+
122
+ beforeEach(() => {
123
+ vi.clearAllMocks();
124
+ });
125
+
126
+ it('resolves a workspace tier through workspace_subscriptions without joining protected product tables', async () => {
127
+ const workspaceQuery = createSingleWorkspaceQuery({
128
+ id: workspaceOneId,
129
+ name: 'Workspace One',
130
+ personal: false,
131
+ workspace_members: [{ user_id: 'user-1' }],
132
+ });
133
+ const subscriptionQuery = createSubscriptionLookupQuery([
134
+ {
135
+ ws_id: workspaceOneId,
136
+ created_at: '2026-03-10T00:00:00.000Z',
137
+ product_id: 'product-pro',
138
+ status: 'active',
139
+ },
140
+ ]);
141
+ const productQuery = createSubscriptionProductLookupQuery([
142
+ { id: 'product-pro', tier: 'PRO' },
143
+ ]);
144
+
145
+ mockCreateClient.mockResolvedValue(
146
+ createUserClient({
147
+ userId: 'user-1',
148
+ workspaceQuery,
149
+ })
150
+ );
151
+ mockCreateAdminClient.mockResolvedValue(
152
+ createAdminClient({ productQuery, subscriptionQuery })
153
+ );
154
+
155
+ const workspace = await getWorkspace(workspaceOneId);
156
+
157
+ expect(workspace?.id).toBe(workspaceOneId);
158
+ expect(workspace?.joined).toBe(true);
159
+ expect(workspace?.tier).toBe('PRO');
160
+ expect(workspaceQuery.select).toHaveBeenCalledWith(
161
+ '*, workspace_members!inner(user_id)'
162
+ );
163
+ expect(subscriptionQuery.select).toHaveBeenCalledWith(
164
+ 'ws_id, created_at, status, product_id'
165
+ );
166
+ expect(productQuery.in).toHaveBeenCalledWith('id', ['product-pro']);
167
+ });
168
+
169
+ it('hydrates workspace list tiers from the subscription lookup query', async () => {
170
+ const workspacesQuery = createWorkspacesQuery([
171
+ {
172
+ id: workspaceOneId,
173
+ name: 'Workspace One',
174
+ avatar_url: null,
175
+ logo_url: null,
176
+ personal: false,
177
+ created_at: '2026-03-10T00:00:00.000Z',
178
+ workspace_members: [{ user_id: 'user-1' }],
179
+ },
180
+ {
181
+ id: workspaceTwoId,
182
+ name: 'Workspace Two',
183
+ avatar_url: null,
184
+ logo_url: null,
185
+ personal: false,
186
+ created_at: '2026-03-11T00:00:00.000Z',
187
+ workspace_members: [{ user_id: 'user-1' }],
188
+ },
189
+ ]);
190
+ const subscriptionQuery = createSubscriptionLookupQuery([
191
+ {
192
+ ws_id: workspaceOneId,
193
+ created_at: '2026-03-10T00:00:00.000Z',
194
+ product_id: 'product-plus',
195
+ status: 'active',
196
+ },
197
+ {
198
+ ws_id: workspaceTwoId,
199
+ created_at: '2026-03-11T00:00:00.000Z',
200
+ product_id: 'product-enterprise',
201
+ status: 'canceled',
202
+ },
203
+ ]);
204
+ const productQuery = createSubscriptionProductLookupQuery([
205
+ { id: 'product-plus', tier: 'PLUS' },
206
+ { id: 'product-enterprise', tier: 'ENTERPRISE' },
207
+ ]);
208
+
209
+ mockCreateClient.mockResolvedValue(
210
+ createUserClient({
211
+ userId: 'user-1',
212
+ workspacesQuery,
213
+ })
214
+ );
215
+ mockCreateAdminClient.mockResolvedValue(
216
+ createAdminClient({ productQuery, subscriptionQuery })
217
+ );
218
+
219
+ const workspaces = await getWorkspaces();
220
+
221
+ expect(workspaces).toEqual([
222
+ expect.objectContaining({ id: workspaceOneId, tier: 'PLUS' }),
223
+ expect.objectContaining({ id: workspaceTwoId, tier: null }),
224
+ ]);
225
+ expect(workspacesQuery.select).toHaveBeenCalledWith(
226
+ 'id, name, avatar_url, logo_url, personal, created_at, workspace_members!inner(user_id)'
227
+ );
228
+ expect(subscriptionQuery.in).toHaveBeenCalledWith('ws_id', [
229
+ workspaceOneId,
230
+ workspaceTwoId,
231
+ ]);
232
+ expect(productQuery.in).toHaveBeenCalledWith('id', [
233
+ 'product-plus',
234
+ 'product-enterprise',
235
+ ]);
236
+ });
237
+
238
+ it('resolves workspace handles through direct lookup', async () => {
239
+ const workspaceQuery = createSingleWorkspaceQuery({
240
+ id: workspaceOneId,
241
+ name: 'Workspace Handle Match',
242
+ personal: false,
243
+ workspace_members: [{ user_id: 'user-1' }],
244
+ });
245
+ const subscriptionQuery = createSubscriptionLookupQuery([]);
246
+ const productQuery = createSubscriptionProductLookupQuery([]);
247
+
248
+ mockCreateClient.mockResolvedValue(
249
+ createUserClient({
250
+ userId: 'user-1',
251
+ workspaceQuery,
252
+ })
253
+ );
254
+ mockCreateAdminClient.mockResolvedValue(
255
+ createAdminClient({ productQuery, subscriptionQuery })
256
+ );
257
+
258
+ const workspace = await getWorkspace('traffic-advice');
259
+
260
+ expect(workspace?.id).toBe(workspaceOneId);
261
+ expect(workspaceQuery.eq).toHaveBeenCalledWith('handle', 'traffic-advice');
262
+ });
263
+
264
+ it('looks up fixture-style Postgres UUIDs by workspace id', async () => {
265
+ const fixtureWorkspaceId = '00000000-0000-0000-0000-000000000003';
266
+ const workspaceQuery = createSingleWorkspaceQuery({
267
+ id: fixtureWorkspaceId,
268
+ name: 'Fixture Workspace',
269
+ personal: false,
270
+ workspace_members: [{ user_id: 'user-1' }],
271
+ });
272
+ const subscriptionQuery = createSubscriptionLookupQuery([]);
273
+ const productQuery = createSubscriptionProductLookupQuery([]);
274
+
275
+ mockCreateClient.mockResolvedValue(
276
+ createUserClient({
277
+ userId: 'user-1',
278
+ workspaceQuery,
279
+ })
280
+ );
281
+ mockCreateAdminClient.mockResolvedValue(
282
+ createAdminClient({ productQuery, subscriptionQuery })
283
+ );
284
+
285
+ const workspace = await getWorkspace(fixtureWorkspaceId);
286
+
287
+ expect(workspace?.id).toBe(fixtureWorkspaceId);
288
+ expect(workspaceQuery.eq).toHaveBeenCalledWith('id', fixtureWorkspaceId);
289
+ expect(workspaceQuery.eq).not.toHaveBeenCalledWith(
290
+ 'handle',
291
+ fixtureWorkspaceId
292
+ );
293
+ });
294
+
295
+ it('returns null without querying when the workspace id is malformed', async () => {
296
+ const fromMock = vi.fn();
297
+ const getUserMock = vi.fn();
298
+
299
+ mockCreateClient.mockResolvedValue({
300
+ auth: { getUser: getUserMock },
301
+ from: fromMock,
302
+ });
303
+
304
+ const workspace = await getWorkspace('.well-known');
305
+
306
+ expect(workspace).toBeNull();
307
+ expect(getUserMock).not.toHaveBeenCalled();
308
+ expect(fromMock).not.toHaveBeenCalled();
309
+ });
310
+ });
311
+
312
+ describe('normalizeWorkspaceId', () => {
313
+ beforeEach(() => {
314
+ vi.clearAllMocks();
315
+ });
316
+
317
+ it('keeps root workspace UUID unchanged without resolving personal workspace', async () => {
318
+ const rootWorkspaceId = '00000000-0000-0000-0000-000000000000';
319
+ const fromMock = vi.fn();
320
+ const getUserMock = vi.fn();
321
+
322
+ mockCreateClient.mockResolvedValue({
323
+ auth: { getUser: getUserMock },
324
+ from: fromMock,
325
+ });
326
+
327
+ const resolved = await normalizeWorkspaceId(rootWorkspaceId);
328
+
329
+ expect(resolved).toBe(rootWorkspaceId);
330
+ expect(getUserMock).not.toHaveBeenCalled();
331
+ expect(fromMock).not.toHaveBeenCalled();
332
+ });
333
+
334
+ it('keeps fixture-style Postgres UUIDs unchanged without handle lookup', async () => {
335
+ const fixtureWorkspaceId = '00000000-0000-0000-0000-000000000003';
336
+ const fromMock = vi.fn();
337
+ const getUserMock = vi.fn();
338
+
339
+ mockCreateClient.mockResolvedValue({
340
+ auth: { getUser: getUserMock },
341
+ from: fromMock,
342
+ });
343
+
344
+ const resolved = await normalizeWorkspaceId(fixtureWorkspaceId);
345
+
346
+ expect(resolved).toBe(fixtureWorkspaceId);
347
+ expect(getUserMock).not.toHaveBeenCalled();
348
+ expect(fromMock).not.toHaveBeenCalled();
349
+ });
350
+
351
+ it('maps internal slug to root workspace UUID without personal workspace lookup', async () => {
352
+ const rootWorkspaceId = '00000000-0000-0000-0000-000000000000';
353
+ const fromMock = vi.fn();
354
+ const getUserMock = vi.fn();
355
+
356
+ mockCreateClient.mockResolvedValue({
357
+ auth: { getUser: getUserMock },
358
+ from: fromMock,
359
+ });
360
+
361
+ const resolved = await normalizeWorkspaceId('internal');
362
+
363
+ expect(resolved).toBe(rootWorkspaceId);
364
+ expect(getUserMock).not.toHaveBeenCalled();
365
+ expect(fromMock).not.toHaveBeenCalled();
366
+ });
367
+
368
+ it('resolves personal slug to the authenticated user personal workspace', async () => {
369
+ const query = {
370
+ select: vi.fn(),
371
+ eq: vi.fn(),
372
+ maybeSingle: vi.fn(),
373
+ };
374
+
375
+ query.select.mockReturnValue(query);
376
+ query.eq.mockReturnValue(query);
377
+ query.maybeSingle.mockResolvedValue({
378
+ data: { id: 'personal-ws-id' },
379
+ error: null,
380
+ });
381
+
382
+ const getUserMock = vi.fn().mockResolvedValue({
383
+ data: { user: { id: 'user-1' } },
384
+ });
385
+ const fromMock = vi.fn((table: string) => {
386
+ if (table !== 'workspaces') {
387
+ throw new Error(`Unexpected table lookup: ${table}`);
388
+ }
389
+ return query;
390
+ });
391
+
392
+ mockCreateClient.mockResolvedValue({
393
+ auth: { getUser: getUserMock },
394
+ from: fromMock,
395
+ });
396
+
397
+ const resolved = await normalizeWorkspaceId('personal');
398
+
399
+ expect(resolved).toBe('personal-ws-id');
400
+ expect(getUserMock).toHaveBeenCalledTimes(1);
401
+ expect(fromMock).toHaveBeenCalledWith('workspaces');
402
+ expect(query.select).toHaveBeenCalledWith(
403
+ 'id, workspace_members!inner(user_id, type)'
404
+ );
405
+ expect(query.eq).toHaveBeenCalledWith('personal', true);
406
+ expect(query.eq).toHaveBeenCalledWith(
407
+ 'workspace_members.user_id',
408
+ 'user-1'
409
+ );
410
+ expect(query.eq).toHaveBeenCalledWith('workspace_members.type', 'MEMBER');
411
+ });
412
+
413
+ it('resolves handle via admin fallback when request-scoped lookup cannot see workspace', async () => {
414
+ const requestScopedQuery = {
415
+ select: vi.fn(),
416
+ eq: vi.fn(),
417
+ maybeSingle: vi.fn(),
418
+ };
419
+ requestScopedQuery.select.mockReturnValue(requestScopedQuery);
420
+ requestScopedQuery.eq.mockReturnValue(requestScopedQuery);
421
+ requestScopedQuery.maybeSingle.mockResolvedValue({
422
+ data: null,
423
+ error: null,
424
+ });
425
+
426
+ const adminQuery = {
427
+ select: vi.fn(),
428
+ eq: vi.fn(),
429
+ maybeSingle: vi.fn(),
430
+ };
431
+ adminQuery.select.mockReturnValue(adminQuery);
432
+ adminQuery.eq.mockReturnValue(adminQuery);
433
+ adminQuery.maybeSingle.mockResolvedValue({
434
+ data: { id: '33333333-3333-4333-8333-333333333333' },
435
+ error: null,
436
+ });
437
+
438
+ mockCreateClient.mockResolvedValue({
439
+ auth: { getUser: vi.fn() },
440
+ from: vi.fn((table: string) => {
441
+ if (table !== 'workspaces') {
442
+ throw new Error(`Unexpected table lookup: ${table}`);
443
+ }
444
+ return requestScopedQuery;
445
+ }),
446
+ });
447
+
448
+ mockCreateAdminClient.mockResolvedValue({
449
+ from: vi.fn((table: string) => {
450
+ if (table !== 'workspaces') {
451
+ throw new Error(`Unexpected admin table lookup: ${table}`);
452
+ }
453
+ return adminQuery;
454
+ }),
455
+ });
456
+
457
+ const resolved = await normalizeWorkspaceId('triple-sss');
458
+
459
+ expect(resolved).toBe('33333333-3333-4333-8333-333333333333');
460
+ expect(requestScopedQuery.eq).toHaveBeenCalledWith('handle', 'triple-sss');
461
+ expect(adminQuery.eq).toHaveBeenCalledWith('handle', 'triple-sss');
462
+ });
463
+ });
464
+
465
+ describe('getPermissions membership type gate', () => {
466
+ beforeEach(() => {
467
+ vi.clearAllMocks();
468
+ });
469
+
470
+ it('resolves personal slug with an explicit app-session user', async () => {
471
+ const personalWorkspaceId = '11111111-1111-4111-8111-111111111111';
472
+ const adminAuthGetUser = vi.fn();
473
+
474
+ const personalWorkspaceQuery = {
475
+ select: vi.fn(),
476
+ eq: vi.fn(),
477
+ maybeSingle: vi.fn(),
478
+ };
479
+ personalWorkspaceQuery.select.mockReturnValue(personalWorkspaceQuery);
480
+ personalWorkspaceQuery.eq.mockReturnValue(personalWorkspaceQuery);
481
+ personalWorkspaceQuery.maybeSingle.mockResolvedValue({
482
+ data: { id: personalWorkspaceId },
483
+ error: null,
484
+ });
485
+
486
+ const membershipQuery = {
487
+ select: vi.fn(),
488
+ eq: vi.fn(),
489
+ maybeSingle: vi.fn(),
490
+ };
491
+ membershipQuery.select.mockReturnValue(membershipQuery);
492
+ membershipQuery.eq.mockReturnValue(membershipQuery);
493
+ membershipQuery.maybeSingle.mockResolvedValue({
494
+ data: { type: 'MEMBER' },
495
+ error: null,
496
+ });
497
+
498
+ const roleMembersQuery = {
499
+ select: vi.fn(),
500
+ eq: vi.fn(),
501
+ };
502
+ roleMembersQuery.select.mockReturnValue(roleMembersQuery);
503
+ roleMembersQuery.eq.mockImplementation((field) => {
504
+ if (field === 'workspace_roles.workspace_role_permissions.enabled') {
505
+ return Promise.resolve({ data: [], error: null });
506
+ }
507
+ return roleMembersQuery;
508
+ });
509
+
510
+ const workspaceQuery = {
511
+ select: vi.fn(),
512
+ eq: vi.fn(),
513
+ single: vi.fn(),
514
+ };
515
+ workspaceQuery.select.mockReturnValue(workspaceQuery);
516
+ workspaceQuery.eq.mockReturnValue(workspaceQuery);
517
+ workspaceQuery.single.mockResolvedValue({
518
+ data: { creator_id: 'user-1' },
519
+ error: null,
520
+ });
521
+
522
+ const defaultPermissionsQuery = {
523
+ select: vi.fn(),
524
+ eq: vi.fn(),
525
+ };
526
+ defaultPermissionsQuery.select.mockReturnValue(defaultPermissionsQuery);
527
+ defaultPermissionsQuery.eq.mockImplementation((field) => {
528
+ if (field === 'enabled') {
529
+ return Promise.resolve({ data: [], error: null });
530
+ }
531
+ return defaultPermissionsQuery;
532
+ });
533
+
534
+ const workspacesFrom = vi
535
+ .fn()
536
+ .mockReturnValueOnce(personalWorkspaceQuery)
537
+ .mockReturnValueOnce(workspaceQuery);
538
+
539
+ mockCreateAdminClient.mockResolvedValue({
540
+ auth: { getUser: adminAuthGetUser },
541
+ from: vi.fn((table: string) => {
542
+ if (table === 'workspaces') return workspacesFrom();
543
+ if (table === 'workspace_members') return membershipQuery;
544
+ if (table === 'workspace_role_members') return roleMembersQuery;
545
+ if (table === 'workspace_default_permissions') {
546
+ return defaultPermissionsQuery;
547
+ }
548
+ throw new Error(`Unexpected admin table lookup: ${table}`);
549
+ }),
550
+ });
551
+
552
+ const permissions = await getPermissions({
553
+ user: { id: 'user-1', email: 'user@example.com' },
554
+ wsId: 'personal',
555
+ });
556
+
557
+ expect(permissions?.wsId).toBe(personalWorkspaceId);
558
+ expect(permissions?.membershipType).toBe('MEMBER');
559
+ expect(permissions?.containsPermission('manage_calendar')).toBe(true);
560
+ expect(adminAuthGetUser).not.toHaveBeenCalled();
561
+ expect(personalWorkspaceQuery.eq).toHaveBeenCalledWith('personal', true);
562
+ expect(personalWorkspaceQuery.eq).toHaveBeenCalledWith(
563
+ 'workspace_members.user_id',
564
+ 'user-1'
565
+ );
566
+ expect(membershipQuery.eq).toHaveBeenCalledWith(
567
+ 'ws_id',
568
+ personalWorkspaceId
569
+ );
570
+ expect(roleMembersQuery.eq).toHaveBeenCalledWith(
571
+ 'workspace_roles.ws_id',
572
+ personalWorkspaceId
573
+ );
574
+ expect(workspaceQuery.eq).toHaveBeenCalledWith('id', personalWorkspaceId);
575
+ expect(defaultPermissionsQuery.eq).toHaveBeenCalledWith(
576
+ 'ws_id',
577
+ personalWorkspaceId
578
+ );
579
+ });
580
+
581
+ it('returns null when caller membership is GUEST without enabled guest defaults', async () => {
582
+ const membershipQuery = {
583
+ select: vi.fn(),
584
+ eq: vi.fn(),
585
+ maybeSingle: vi.fn(),
586
+ };
587
+
588
+ membershipQuery.select.mockReturnValue(membershipQuery);
589
+ membershipQuery.eq.mockReturnValue(membershipQuery);
590
+ membershipQuery.maybeSingle.mockResolvedValue({
591
+ data: { type: 'GUEST' },
592
+ error: null,
593
+ });
594
+
595
+ const workspacesQuery = {
596
+ select: vi.fn(),
597
+ eq: vi.fn(),
598
+ maybeSingle: vi.fn(),
599
+ };
600
+
601
+ workspacesQuery.select.mockReturnValue(workspacesQuery);
602
+ workspacesQuery.eq.mockReturnValue(workspacesQuery);
603
+ workspacesQuery.maybeSingle.mockResolvedValue({
604
+ data: { id: '11111111-1111-4111-8111-111111111111' },
605
+ error: null,
606
+ });
607
+
608
+ mockCreateClient.mockResolvedValue({
609
+ auth: {
610
+ getUser: vi.fn().mockResolvedValue({
611
+ data: { user: { id: 'user-1', email: 'user@example.com' } },
612
+ }),
613
+ },
614
+ from: vi.fn((table: string) => {
615
+ if (table === 'workspace_members') return membershipQuery;
616
+ if (table === 'workspaces') return workspacesQuery;
617
+ throw new Error(`Unexpected table lookup: ${table}`);
618
+ }),
619
+ });
620
+
621
+ const workspaceQuery = {
622
+ select: vi.fn(),
623
+ eq: vi.fn(),
624
+ single: vi.fn(),
625
+ };
626
+ workspaceQuery.select.mockReturnValue(workspaceQuery);
627
+ workspaceQuery.eq.mockReturnValue(workspaceQuery);
628
+ workspaceQuery.single.mockResolvedValue({
629
+ data: { creator_id: 'owner-1' },
630
+ error: null,
631
+ });
632
+
633
+ const defaultPermissionsQuery = {
634
+ select: vi.fn(),
635
+ eq: vi.fn(),
636
+ };
637
+ defaultPermissionsQuery.select.mockReturnValue(defaultPermissionsQuery);
638
+ defaultPermissionsQuery.eq.mockImplementation((field) => {
639
+ if (field === 'enabled') {
640
+ return Promise.resolve({ data: [], error: null });
641
+ }
642
+ return defaultPermissionsQuery;
643
+ });
644
+
645
+ mockCreateAdminClient.mockResolvedValue({
646
+ from: vi.fn((table: string) => {
647
+ if (table === 'workspaces') return workspaceQuery;
648
+ if (table === 'workspace_default_permissions') {
649
+ return defaultPermissionsQuery;
650
+ }
651
+ throw new Error(`Unexpected admin table lookup: ${table}`);
652
+ }),
653
+ });
654
+
655
+ const permissions = await getPermissions({
656
+ wsId: '11111111-1111-4111-8111-111111111111',
657
+ });
658
+
659
+ expect(permissions).toBeNull();
660
+ expect(defaultPermissionsQuery.eq).toHaveBeenCalledWith(
661
+ 'member_type',
662
+ 'GUEST'
663
+ );
664
+ });
665
+
666
+ it('uses guest default permissions for GUEST callers', async () => {
667
+ const membershipQuery = {
668
+ select: vi.fn(),
669
+ eq: vi.fn(),
670
+ maybeSingle: vi.fn(),
671
+ };
672
+
673
+ membershipQuery.select.mockReturnValue(membershipQuery);
674
+ membershipQuery.eq.mockReturnValue(membershipQuery);
675
+ membershipQuery.maybeSingle.mockResolvedValue({
676
+ data: { type: 'GUEST' },
677
+ error: null,
678
+ });
679
+
680
+ mockCreateClient.mockResolvedValue({
681
+ auth: {
682
+ getUser: vi.fn().mockResolvedValue({
683
+ data: { user: { id: 'user-1', email: 'user@example.com' } },
684
+ }),
685
+ },
686
+ from: vi.fn((table: string) => {
687
+ if (table === 'workspace_members') return membershipQuery;
688
+ throw new Error(`Unexpected table lookup: ${table}`);
689
+ }),
690
+ });
691
+
692
+ const workspaceQuery = {
693
+ select: vi.fn(),
694
+ eq: vi.fn(),
695
+ single: vi.fn(),
696
+ };
697
+ workspaceQuery.select.mockReturnValue(workspaceQuery);
698
+ workspaceQuery.eq.mockReturnValue(workspaceQuery);
699
+ workspaceQuery.single.mockResolvedValue({
700
+ data: { creator_id: 'owner-1' },
701
+ error: null,
702
+ });
703
+
704
+ const defaultPermissionsQuery = {
705
+ select: vi.fn(),
706
+ eq: vi.fn(),
707
+ };
708
+ defaultPermissionsQuery.select.mockReturnValue(defaultPermissionsQuery);
709
+ defaultPermissionsQuery.eq.mockImplementation((field) => {
710
+ if (field === 'enabled') {
711
+ return Promise.resolve({
712
+ data: [{ permission: 'manage_workspace_roles' }],
713
+ error: null,
714
+ });
715
+ }
716
+ return defaultPermissionsQuery;
717
+ });
718
+
719
+ mockCreateAdminClient.mockResolvedValue({
720
+ from: vi.fn((table: string) => {
721
+ if (table === 'workspaces') return workspaceQuery;
722
+ if (table === 'workspace_default_permissions') {
723
+ return defaultPermissionsQuery;
724
+ }
725
+ throw new Error(`Unexpected admin table lookup: ${table}`);
726
+ }),
727
+ });
728
+
729
+ const permissions = await getPermissions({
730
+ wsId: '11111111-1111-4111-8111-111111111111',
731
+ });
732
+
733
+ expect(permissions?.membershipType).toBe('GUEST');
734
+ expect(permissions?.permissions).toEqual(['manage_workspace_roles']);
735
+ expect(permissions?.containsPermission('manage_workspace_roles')).toBe(
736
+ true
737
+ );
738
+ expect(permissions?.containsPermission('manage_workspace_members')).toBe(
739
+ false
740
+ );
741
+ expect(defaultPermissionsQuery.eq).toHaveBeenCalledWith(
742
+ 'member_type',
743
+ 'GUEST'
744
+ );
745
+ });
746
+ });
747
+
748
+ function createUserClient({
749
+ userId,
750
+ workspaceQuery,
751
+ workspacesQuery,
752
+ }: {
753
+ userId: string;
754
+ workspaceQuery?: ReturnType<typeof createSingleWorkspaceQuery>;
755
+ workspacesQuery?: ReturnType<typeof createWorkspacesQuery>;
756
+ }) {
757
+ return {
758
+ auth: {
759
+ getUser: vi.fn().mockResolvedValue({
760
+ data: { user: { id: userId, email: 'user@example.com' } },
761
+ }),
762
+ },
763
+ from: vi.fn((table: string) => {
764
+ if (table !== 'workspaces') {
765
+ throw new Error(`Unexpected table lookup: ${table}`);
766
+ }
767
+
768
+ if (workspaceQuery) return workspaceQuery;
769
+ if (workspacesQuery) return workspacesQuery;
770
+
771
+ throw new Error('Missing workspace query mock');
772
+ }),
773
+ };
774
+ }
775
+
776
+ function createAdminClient({
777
+ productQuery,
778
+ subscriptionQuery,
779
+ }: {
780
+ productQuery: ReturnType<typeof createSubscriptionProductLookupQuery>;
781
+ subscriptionQuery: ReturnType<typeof createSubscriptionLookupQuery>;
782
+ }) {
783
+ return {
784
+ from: vi.fn((table: string) => {
785
+ if (table !== 'workspace_subscriptions') {
786
+ throw new Error(`Unexpected admin table lookup: ${table}`);
787
+ }
788
+
789
+ return subscriptionQuery;
790
+ }),
791
+ schema: vi.fn((schemaName: string) => {
792
+ if (schemaName !== 'private') {
793
+ throw new Error(`Unexpected admin schema lookup: ${schemaName}`);
794
+ }
795
+
796
+ return {
797
+ from: vi.fn((table: string) => {
798
+ if (table !== 'workspace_subscription_products') {
799
+ throw new Error(`Unexpected private table lookup: ${table}`);
800
+ }
801
+
802
+ return productQuery;
803
+ }),
804
+ };
805
+ }),
806
+ };
807
+ }
808
+
809
+ function createSingleWorkspaceQuery(data: Record<string, unknown>) {
810
+ const query = {
811
+ select: vi.fn(),
812
+ eq: vi.fn(),
813
+ single: vi.fn(),
814
+ };
815
+
816
+ query.select.mockReturnValue(query);
817
+ query.eq.mockReturnValue(query);
818
+ query.single.mockResolvedValue({ data, error: null });
819
+
820
+ return query;
821
+ }
822
+
823
+ function createWorkspacesQuery(data: Array<Record<string, unknown>>) {
824
+ const query = {
825
+ select: vi.fn(),
826
+ eq: vi.fn(),
827
+ };
828
+
829
+ query.select.mockReturnValue(query);
830
+ query.eq.mockResolvedValue({ data, error: null });
831
+
832
+ return query;
833
+ }
834
+
835
+ function createSubscriptionLookupQuery(data: Array<Record<string, unknown>>) {
836
+ const query = {
837
+ select: vi.fn(),
838
+ in: vi.fn(),
839
+ };
840
+
841
+ query.select.mockReturnValue(query);
842
+ query.in.mockResolvedValue({ data, error: null });
843
+
844
+ return query;
845
+ }
846
+
847
+ function createSubscriptionProductLookupQuery(
848
+ data: Array<Record<string, unknown>>
849
+ ) {
850
+ const query = {
851
+ select: vi.fn(),
852
+ in: vi.fn(),
853
+ };
854
+
855
+ query.select.mockReturnValue(query);
856
+ query.in.mockResolvedValue({ data, error: null });
857
+
858
+ return query;
859
+ }