@tuturuuu/utils 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.22.0](https://github.com/tutur3u/platform/compare/utils-v0.21.0...utils-v0.22.0) (2026-08-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * **chat:** add connected site sync bridge ([#5078](https://github.com/tutur3u/platform/issues/5078)) ([3de1a07](https://github.com/tutur3u/platform/commit/3de1a07dfa2fec96982aa0d116e003d0aa3fe47c))
9
+ * **onboarding:** connect product guidance across apps ([68cf626](https://github.com/tutur3u/platform/commit/68cf626c9650e5044b6c123f9423a6cebf1bba9a))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **ai:** accept rich embedding payloads ([a65b13f](https://github.com/tutur3u/platform/commit/a65b13fa80034959e5f18ee4515b3081002558e2))
15
+ * **tooling:** support webpack verification builds ([5275e10](https://github.com/tutur3u/platform/commit/5275e10c4007ae92e84be6c057a79f223980700c))
16
+
17
+ ## [0.21.0](https://github.com/tutur3u/platform/compare/utils-v0.20.0...utils-v0.21.0) (2026-07-28)
18
+
19
+
20
+ ### Features
21
+
22
+ * **git:** add fast repository satellite ([51982ae](https://github.com/tutur3u/platform/commit/51982ae8618bb7463e30c97f6e731551ec673660))
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * **workspaces:** preserve creator administration access ([cea32ea](https://github.com/tutur3u/platform/commit/cea32ea22acaae6be6e39552be4af47e6f4dcdc7))
28
+
3
29
  ## [0.20.0](https://github.com/tutur3u/platform/compare/utils-v0.19.0...utils-v0.20.0) (2026-07-27)
4
30
 
5
31
 
package/biome.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "root": false,
3
- "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
3
+ "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
4
4
  "extends": "//"
5
5
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tuturuuu/utils",
3
3
  "license": "MIT",
4
- "version": "0.20.0",
4
+ "version": "0.22.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -20,21 +20,21 @@
20
20
  "@noble/ciphers": "^2.2.0",
21
21
  "@noble/hashes": "^2.2.0",
22
22
  "@tanstack/react-query": "^5.101.4",
23
- "@tiptap/core": "3.28.0",
24
- "@tiptap/react": "3.28.0",
23
+ "@tiptap/core": "3.29.2",
24
+ "@tiptap/react": "3.29.2",
25
25
  "@tuturuuu/google": "0.1.0",
26
26
  "@tuturuuu/icons": "0.1.0",
27
- "@tuturuuu/internal-api": "0.24.0",
27
+ "@tuturuuu/internal-api": "0.27.0",
28
28
  "@tuturuuu/supabase": "0.5.0",
29
29
  "@upstash/ratelimit": "^2.0.8",
30
- "@upstash/redis": "^1.38.0",
30
+ "@upstash/redis": "^1.38.1",
31
31
  "clsx": "^2.1.1",
32
32
  "date-fns": "^4.4.0",
33
33
  "dayjs": "^1.11.21",
34
34
  "diff": "^9.0.0",
35
- "effect": "^3.22.0",
35
+ "effect": "^3.22.1",
36
36
  "moment": "^2.30.1",
37
- "next": "^16.3.0-preview.9",
37
+ "next": "^16.3.0",
38
38
  "prosemirror-model": "^1.25.11",
39
39
  "react": "^19.2.8",
40
40
  "react-dom": "^19.2.8",
@@ -45,14 +45,14 @@
45
45
  "zod": "^4.4.3"
46
46
  },
47
47
  "devDependencies": {
48
- "@tuturuuu/types": "0.23.0",
48
+ "@tuturuuu/types": "0.26.0",
49
49
  "@tuturuuu/typescript-config": "0.1.1",
50
50
  "@types/diff": "^8.0.0",
51
- "@types/node": "^26.1.1",
52
- "@types/react": "^19.2.17",
53
- "@vitejs/plugin-react": "^6.0.4",
51
+ "@types/node": "^26.1.2",
52
+ "@types/react": "^19.2.18",
53
+ "@vitejs/plugin-react": "^6.0.5",
54
54
  "typescript": "7.0.2",
55
- "vite": "^8.1.5",
55
+ "vite": "^8.2.0",
56
56
  "vitest": "^4.1.10"
57
57
  },
58
58
  "exports": {
@@ -159,6 +159,35 @@ describe('guardApiProxyRequest', () => {
159
159
  expect(mocks.validateEmoji).not.toHaveBeenCalled();
160
160
  });
161
161
 
162
+ it('can skip form-content validation without skipping payload limits', async () => {
163
+ mocks.validateEmoji.mockResolvedValue(
164
+ new Response(JSON.stringify({ error: 'Bad Request' }), { status: 400 })
165
+ );
166
+
167
+ const { guardApiProxyRequest } = await import('../api-proxy-guard.js');
168
+ const response = await guardApiProxyRequest(
169
+ makeRequest('/v1/embeddings', 'POST'),
170
+ {
171
+ prefixBase: 'proxy:test:ai',
172
+ skipContentValidation: true,
173
+ }
174
+ );
175
+
176
+ expect(response).toBeNull();
177
+ expect(mocks.validateEmoji).not.toHaveBeenCalled();
178
+
179
+ const oversized = await guardApiProxyRequest(
180
+ makeRequest('/v1/embeddings', 'POST', {
181
+ 'content-length': `${1024 * 1024 + 1}`,
182
+ }),
183
+ {
184
+ prefixBase: 'proxy:test:ai',
185
+ skipContentValidation: true,
186
+ }
187
+ );
188
+ expect(oversized?.status).toBe(413);
189
+ });
190
+
162
191
  it('returns an IP-block response when the client is blocked', async () => {
163
192
  vi.stubEnv('NODE_ENV', 'production');
164
193
  mocks.extractIp.mockReturnValue('1.2.3.4');
@@ -298,6 +298,7 @@ describe('Portless app origin registry', () => {
298
298
  'https://external.tuturuuu.localhost',
299
299
  'https://finance.tuturuuu.localhost',
300
300
  'https://forms.tuturuuu.localhost',
301
+ 'https://git.tuturuuu.localhost',
301
302
  'https://hive.tuturuuu.localhost',
302
303
  'https://realtime.hive.tuturuuu.localhost',
303
304
  'https://infra.tuturuuu.localhost',
@@ -32,6 +32,7 @@ describe('workspace permission catalog', () => {
32
32
  expect(permissionIds).not.toContain('view_infrastructure');
33
33
  expect(permissionIds).not.toContain('manage_infrastructure_stress_tests');
34
34
  expect(permissionIds).not.toContain('manage_internal_accounts');
35
+ expect(permissionIds).not.toContain('manage_git_repositories');
35
36
  expect(permissionIds).not.toContain('manage_workspace_secrets');
36
37
  });
37
38
 
@@ -42,6 +43,7 @@ describe('workspace permission catalog', () => {
42
43
  }).map((permission) => permission.id);
43
44
 
44
45
  expect(rootPermissionIds).toContain('manage_internal_accounts');
46
+ expect(rootPermissionIds).toContain('manage_git_repositories');
45
47
  });
46
48
 
47
49
  it('keeps internal account permission translations in shared role editors', () => {
@@ -75,6 +77,7 @@ describe('workspace permission catalog', () => {
75
77
  expect(permissionIds).toContain('view_infrastructure');
76
78
  expect(permissionIds).toContain('manage_infrastructure_stress_tests');
77
79
  expect(permissionIds).toContain('manage_internal_accounts');
80
+ expect(permissionIds).toContain('manage_git_repositories');
78
81
  expect(permissionIds).toContain('manage_external_migrations');
79
82
  expect(permissionIds).toContain('manage_workspace_secrets');
80
83
  expect(new Set(permissionIds).size).toBe(permissionIds.length);
@@ -560,24 +560,153 @@ describe('getPermissions membership type gate', () => {
560
560
  expect(adminAuthGetUser).not.toHaveBeenCalled();
561
561
  expect(personalWorkspaceQuery.eq).toHaveBeenCalledWith('personal', true);
562
562
  expect(personalWorkspaceQuery.eq).toHaveBeenCalledWith(
563
- 'workspace_members.user_id',
563
+ 'creator_id',
564
564
  'user-1'
565
565
  );
566
566
  expect(membershipQuery.eq).toHaveBeenCalledWith(
567
567
  'ws_id',
568
568
  personalWorkspaceId
569
569
  );
570
- expect(roleMembersQuery.eq).toHaveBeenCalledWith(
571
- 'workspace_roles.ws_id',
572
- personalWorkspaceId
573
- );
574
570
  expect(workspaceQuery.eq).toHaveBeenCalledWith('id', personalWorkspaceId);
575
- expect(defaultPermissionsQuery.eq).toHaveBeenCalledWith(
576
- 'ws_id',
577
- personalWorkspaceId
571
+ expect(roleMembersQuery.select).not.toHaveBeenCalled();
572
+ expect(defaultPermissionsQuery.select).not.toHaveBeenCalled();
573
+ });
574
+
575
+ it('grants full member permissions to a creator with a missing membership row', async () => {
576
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
577
+ const membershipQuery = {
578
+ select: vi.fn(),
579
+ eq: vi.fn(),
580
+ maybeSingle: vi.fn(),
581
+ };
582
+ membershipQuery.select.mockReturnValue(membershipQuery);
583
+ membershipQuery.eq.mockReturnValue(membershipQuery);
584
+ membershipQuery.maybeSingle.mockResolvedValue({
585
+ data: null,
586
+ error: null,
587
+ });
588
+
589
+ const workspaceQuery = {
590
+ select: vi.fn(),
591
+ eq: vi.fn(),
592
+ single: vi.fn(),
593
+ };
594
+ workspaceQuery.select.mockReturnValue(workspaceQuery);
595
+ workspaceQuery.eq.mockReturnValue(workspaceQuery);
596
+ workspaceQuery.single.mockResolvedValue({
597
+ data: { creator_id: 'user-1' },
598
+ error: null,
599
+ });
600
+
601
+ mockCreateAdminClient.mockResolvedValue({
602
+ from: vi.fn((table: string) => {
603
+ if (table === 'workspace_members') return membershipQuery;
604
+ if (table === 'workspaces') return workspaceQuery;
605
+ throw new Error(`Unexpected admin table lookup: ${table}`);
606
+ }),
607
+ });
608
+
609
+ const permissions = await getPermissions({
610
+ user: { id: 'user-1', email: 'creator@example.com' },
611
+ wsId: workspaceId,
612
+ });
613
+
614
+ expect(permissions?.membershipType).toBe('MEMBER');
615
+ expect(permissions?.containsPermission('manage_workspace_settings')).toBe(
616
+ true
617
+ );
618
+ expect(permissions?.containsPermission('manage_workspace_members')).toBe(
619
+ true
620
+ );
621
+ });
622
+
623
+ it('normalizes a creator GUEST membership to effective MEMBER permissions', async () => {
624
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
625
+ const membershipQuery = {
626
+ select: vi.fn(),
627
+ eq: vi.fn(),
628
+ maybeSingle: vi.fn(),
629
+ };
630
+ membershipQuery.select.mockReturnValue(membershipQuery);
631
+ membershipQuery.eq.mockReturnValue(membershipQuery);
632
+ membershipQuery.maybeSingle.mockResolvedValue({
633
+ data: { type: 'GUEST' },
634
+ error: null,
635
+ });
636
+
637
+ const workspaceQuery = {
638
+ select: vi.fn(),
639
+ eq: vi.fn(),
640
+ single: vi.fn(),
641
+ };
642
+ workspaceQuery.select.mockReturnValue(workspaceQuery);
643
+ workspaceQuery.eq.mockReturnValue(workspaceQuery);
644
+ workspaceQuery.single.mockResolvedValue({
645
+ data: { creator_id: 'user-1' },
646
+ error: null,
647
+ });
648
+
649
+ mockCreateAdminClient.mockResolvedValue({
650
+ from: vi.fn((table: string) => {
651
+ if (table === 'workspace_members') return membershipQuery;
652
+ if (table === 'workspaces') return workspaceQuery;
653
+ throw new Error(`Unexpected admin table lookup: ${table}`);
654
+ }),
655
+ });
656
+
657
+ const permissions = await getPermissions({
658
+ user: { id: 'user-1', email: 'creator@example.com' },
659
+ wsId: workspaceId,
660
+ });
661
+
662
+ expect(permissions?.membershipType).toBe('MEMBER');
663
+ expect(permissions?.containsPermission('manage_workspace_roles')).toBe(
664
+ true
578
665
  );
579
666
  });
580
667
 
668
+ it('denies an unrelated user when the membership row is missing', async () => {
669
+ const workspaceId = '11111111-1111-4111-8111-111111111111';
670
+ const membershipQuery = {
671
+ select: vi.fn(),
672
+ eq: vi.fn(),
673
+ maybeSingle: vi.fn(),
674
+ };
675
+ membershipQuery.select.mockReturnValue(membershipQuery);
676
+ membershipQuery.eq.mockReturnValue(membershipQuery);
677
+ membershipQuery.maybeSingle.mockResolvedValue({
678
+ data: null,
679
+ error: null,
680
+ });
681
+
682
+ const workspaceQuery = {
683
+ select: vi.fn(),
684
+ eq: vi.fn(),
685
+ single: vi.fn(),
686
+ };
687
+ workspaceQuery.select.mockReturnValue(workspaceQuery);
688
+ workspaceQuery.eq.mockReturnValue(workspaceQuery);
689
+ workspaceQuery.single.mockResolvedValue({
690
+ data: { creator_id: 'owner-1' },
691
+ error: null,
692
+ });
693
+
694
+ mockCreateAdminClient.mockResolvedValue({
695
+ from: vi.fn((table: string) => {
696
+ if (table === 'workspace_members') return membershipQuery;
697
+ if (table === 'workspaces') return workspaceQuery;
698
+ throw new Error(`Unexpected admin table lookup: ${table}`);
699
+ }),
700
+ });
701
+
702
+ const permissions = await getPermissions({
703
+ user: { id: 'user-1', email: 'unrelated@example.com' },
704
+ wsId: workspaceId,
705
+ });
706
+
707
+ expect(permissions).toBeNull();
708
+ });
709
+
581
710
  it('returns a read-only permission context for a guest without defaults', async () => {
582
711
  const membershipQuery = {
583
712
  select: vi.fn(),
@@ -88,6 +88,7 @@ export type GuardOptions = {
88
88
  additionalRoutePolicies?: ProxyRoutePolicy[];
89
89
  prefixBase: string;
90
90
  routePolicies?: ProxyRoutePolicy[];
91
+ skipContentValidation?: boolean;
91
92
  trustedBypassRules?: TrustedProxyBypassRule[];
92
93
  };
93
94
 
@@ -1385,6 +1386,10 @@ export async function guardApiProxyRequest(
1385
1386
  ? ['snapshot']
1386
1387
  : undefined;
1387
1388
 
1389
+ if (options.skipContentValidation) {
1390
+ return null;
1391
+ }
1392
+
1388
1393
  return (
1389
1394
  (await validateRequestEmojiLimit(req, {
1390
1395
  allowDescriptionYjsState,
@@ -116,6 +116,10 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
116
116
  name: 'forms',
117
117
  url: 'https://forms.tuturuuu.com',
118
118
  },
119
+ {
120
+ name: 'git',
121
+ url: 'https://git.tuturuuu.com',
122
+ },
119
123
  {
120
124
  name: 'hive',
121
125
  url: 'https://hive.tuturuuu.com',
@@ -231,6 +235,10 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
231
235
  name: 'forms',
232
236
  url: getTuturuuuPortlessAppOrigin('forms'),
233
237
  },
238
+ {
239
+ name: 'git',
240
+ url: getTuturuuuPortlessAppOrigin('git'),
241
+ },
234
242
  {
235
243
  name: 'hive',
236
244
  url: getTuturuuuPortlessAppOrigin('hive'),
@@ -342,6 +350,10 @@ export const LOCALHOST_INTERNAL_APP_DOMAINS = [
342
350
  name: 'forms',
343
351
  url: 'http://localhost:7828',
344
352
  },
353
+ {
354
+ name: 'git',
355
+ url: 'http://localhost:7830',
356
+ },
345
357
  {
346
358
  name: 'hive',
347
359
  url: 'http://localhost:7814',
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  getLaunchableApp,
4
+ getLaunchableAppByHostname,
4
5
  getLaunchableAppByTitle,
5
6
  getLaunchableAppOrigin,
6
7
  LAUNCHABLE_APP_CATEGORIES,
@@ -32,9 +33,20 @@ describe('launchable apps', () => {
32
33
  });
33
34
  expect(getLaunchableAppByTitle('Tuturuuu')?.slug).toBe('platform');
34
35
  expect(getLaunchableAppByTitle('questionnaires')?.slug).toBe('forms');
36
+ expect(getLaunchableAppByTitle('repositories')?.slug).toBe('git');
35
37
  expect(getLaunchableAppByTitle('apps gateway')?.slug).toBe('apps');
36
38
  });
37
39
 
40
+ it('resolves production and Portless hostnames', () => {
41
+ expect(getLaunchableAppByHostname('calendar.tuturuuu.com')?.slug).toBe(
42
+ 'calendar'
43
+ );
44
+ expect(getLaunchableAppByHostname('finance.tuturuuu.localhost')?.slug).toBe(
45
+ 'finance'
46
+ );
47
+ expect(getLaunchableAppByHostname('localhost:7808')?.slug).toBe('finance');
48
+ });
49
+
38
50
  it('keeps retired apps out of the catalog and pins category overrides', () => {
39
51
  expect(LAUNCHABLE_APPS.map((app) => app.slug)).not.toContain('external');
40
52
  expect(LAUNCHABLE_APPS.map((app) => app.slug)).not.toContain('playground');
@@ -45,6 +57,11 @@ describe('launchable apps', () => {
45
57
  expect(getLaunchableApp('external')).toBeNull();
46
58
  expect(getLaunchableApp('playground')).toBeNull();
47
59
  expect(getLaunchableApp('platform')?.category).toBe('productivity');
60
+ expect(getLaunchableApp('git')).toMatchObject({
61
+ defaultPath: '/tutur3u/platform',
62
+ localhostOrigin: 'http://localhost:7830',
63
+ productionUrl: 'https://git.tuturuuu.com',
64
+ });
48
65
  expect(getLaunchableApp('apps')?.category).toBe('miscellaneous');
49
66
  expect(getLaunchableApp('cms')?.category).toBe('operations');
50
67
  expect(getLaunchableApp('rewise')?.category).toBe('ai');
@@ -113,6 +113,18 @@ export const LAUNCHABLE_APPS = [
113
113
  title: 'Drive',
114
114
  workspacePathResolver: workspaceRootPath,
115
115
  },
116
+ {
117
+ aliases: ['Source', 'Repositories', 'GitHub', 'Code'],
118
+ appRoot: 'apps/git',
119
+ category: 'productivity',
120
+ defaultPath: '/tutur3u/platform',
121
+ localhostOrigin: 'http://localhost:7830',
122
+ packageName: '@tuturuuu/git',
123
+ portlessApp: 'git',
124
+ productionUrl: 'https://git.tuturuuu.com',
125
+ slug: 'git',
126
+ title: 'Git',
127
+ },
116
128
  {
117
129
  aliases: ['App Launcher', 'Gateway', 'Apps Gateway'],
118
130
  appRoot: 'apps/apps',
@@ -404,6 +416,29 @@ export function getLaunchableAppByTitle(value?: string | null) {
404
416
  );
405
417
  }
406
418
 
419
+ export function getLaunchableAppByHostname(hostname: string) {
420
+ const normalized = hostname.trim().toLowerCase();
421
+ if (!normalized) return null;
422
+ const normalizedHostname = normalized.split(':')[0];
423
+
424
+ return (
425
+ LAUNCHABLE_APPS.find((app) => {
426
+ if (normalizedHostname === new URL(app.productionUrl).hostname)
427
+ return true;
428
+ if (
429
+ normalizedHostname === `${app.portlessApp}.tuturuuu.localhost` ||
430
+ normalizedHostname === `${app.slug}.tuturuuu.localhost`
431
+ ) {
432
+ return true;
433
+ }
434
+
435
+ return 'localhostOrigin' in app && app.localhostOrigin
436
+ ? normalized === new URL(app.localhostOrigin).host
437
+ : false;
438
+ }) ?? null
439
+ );
440
+ }
441
+
407
442
  function getAutoEnvironment(currentOrigin?: string): LaunchableAppEnvironment {
408
443
  if (currentOrigin?.includes('tuturuuu.localhost')) return 'portless';
409
444
  if (currentOrigin?.includes('localhost')) return 'localhost';
@@ -4,6 +4,7 @@ import {
4
4
  createTuturuuuNextConfig,
5
5
  isTuturuuuNextCacheComponentsEnabled,
6
6
  isTuturuuuNextReactCompilerEnabled,
7
+ isTuturuuuTurbopackRustReactCompilerEnabled,
7
8
  resolveTuturuuuInfrastructureAppUrl,
8
9
  resolveTuturuuuWebAppUrl,
9
10
  TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS,
@@ -267,6 +268,20 @@ describe('isTuturuuuNextCacheComponentsEnabled', () => {
267
268
  });
268
269
  });
269
270
 
271
+ describe('isTuturuuuTurbopackRustReactCompilerEnabled', () => {
272
+ it('keeps the native compiler enabled for normal builds', () => {
273
+ expect(isTuturuuuTurbopackRustReactCompilerEnabled({})).toBe(true);
274
+ });
275
+
276
+ it('disables the Turbopack-only compiler for webpack fallback builds', () => {
277
+ expect(
278
+ isTuturuuuTurbopackRustReactCompilerEnabled({
279
+ NEXT_WEBPACK_BUILD: '1',
280
+ })
281
+ ).toBe(false);
282
+ });
283
+ });
284
+
270
285
  describe('isTuturuuuNextReactCompilerEnabled', () => {
271
286
  it('keeps React Compiler enabled for next dev', () => {
272
287
  expect(
@@ -83,6 +83,12 @@ export function isTuturuuuNextCacheComponentsEnabled(
83
83
  return true;
84
84
  }
85
85
 
86
+ export function isTuturuuuTurbopackRustReactCompilerEnabled(
87
+ env: Environment = process.env
88
+ ) {
89
+ return env.NEXT_WEBPACK_BUILD !== '1';
90
+ }
91
+
86
92
  export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
87
93
  const experimentalConfig = config.experimental ?? {};
88
94
  const imageConfig = config.images ?? {};
@@ -116,7 +122,8 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
116
122
  turbopackFileSystemCacheForBuild:
117
123
  experimentalConfig.turbopackFileSystemCacheForBuild ?? true,
118
124
  turbopackRustReactCompiler:
119
- experimentalConfig.turbopackRustReactCompiler ?? true,
125
+ experimentalConfig.turbopackRustReactCompiler ??
126
+ isTuturuuuTurbopackRustReactCompilerEnabled(),
120
127
  optimizePackageImports: mergeStringArrays(
121
128
  TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS,
122
129
  experimentalConfig.optimizePackageImports
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { LAUNCHABLE_APPS } from './launchable-apps';
3
+ import {
4
+ ONBOARDING_MISSIONS,
5
+ ONBOARDING_PERSONAS,
6
+ recommendOnboardingApps,
7
+ } from './onboarding';
8
+
9
+ describe('connected onboarding manifest', () => {
10
+ it('has one dedicated mission for every launchable app', () => {
11
+ expect(ONBOARDING_MISSIONS.map(({ appSlug }) => appSlug).sort()).toEqual(
12
+ LAUNCHABLE_APPS.map(({ slug }) => slug).sort()
13
+ );
14
+ });
15
+
16
+ it('keeps every mission short and action-oriented', () => {
17
+ expect(
18
+ ONBOARDING_MISSIONS.every(({ capabilities }) => capabilities.length === 3)
19
+ ).toBe(true);
20
+ });
21
+
22
+ it.each(ONBOARDING_PERSONAS)(
23
+ 'returns launchable recommendations for %s',
24
+ (persona) => {
25
+ const recommendations = recommendOnboardingApps({
26
+ goals: [],
27
+ persona,
28
+ });
29
+ const launchable = new Set(LAUNCHABLE_APPS.map(({ slug }) => slug));
30
+
31
+ expect(recommendations.length).toBeGreaterThan(0);
32
+ expect(recommendations.every((slug) => launchable.has(slug))).toBe(true);
33
+ }
34
+ );
35
+ });
@@ -0,0 +1,199 @@
1
+ import { LAUNCHABLE_APPS } from './launchable-apps';
2
+
3
+ export const ONBOARDING_PERSONAS = [
4
+ 'professional',
5
+ 'student',
6
+ 'founder',
7
+ 'executive',
8
+ 'team_leader',
9
+ 'educator',
10
+ 'creator',
11
+ 'developer',
12
+ 'operations',
13
+ ] as const;
14
+
15
+ export const ONBOARDING_GOALS = [
16
+ 'focus',
17
+ 'collaborate',
18
+ 'operate',
19
+ 'learn',
20
+ 'build',
21
+ ] as const;
22
+
23
+ export type OnboardingPersona = (typeof ONBOARDING_PERSONAS)[number];
24
+ export type OnboardingGoal = (typeof ONBOARDING_GOALS)[number];
25
+ export type OnboardingAppSlug = (typeof LAUNCHABLE_APPS)[number]['slug'];
26
+ export type OnboardingActionSafety =
27
+ | 'preview'
28
+ | 'reversible'
29
+ | 'confirmation_required';
30
+
31
+ export type OnboardingMission = {
32
+ appSlug: OnboardingAppSlug;
33
+ capabilities: readonly [string, string, string];
34
+ goal: OnboardingGoal;
35
+ safety: OnboardingActionSafety;
36
+ };
37
+
38
+ export type OnboardingJourneyPreferences = {
39
+ goals: OnboardingGoal[];
40
+ persona: OnboardingPersona | null;
41
+ };
42
+
43
+ export type OnboardingJourneyProgress = OnboardingJourneyPreferences & {
44
+ completedMissionIds: string[];
45
+ dismissedAt: string | null;
46
+ mode: 'standard' | 'employee_test';
47
+ replayApp: OnboardingAppSlug | null;
48
+ revision: number;
49
+ };
50
+
51
+ export const ONBOARDING_JOURNEY_REVISION = 2;
52
+
53
+ export const PERSONA_RECOMMENDATIONS = {
54
+ professional: ['tasks', 'calendar', 'mail', 'drive', 'assistant'],
55
+ student: ['learn', 'rewise', 'calendar', 'tasks', 'mind'],
56
+ founder: ['tasks', 'finance', 'contacts', 'forms', 'assistant'],
57
+ executive: ['platform', 'finance', 'calendar', 'meet', 'assistant'],
58
+ team_leader: ['tasks', 'chat', 'calendar', 'meet', 'contacts'],
59
+ educator: ['teach', 'learn', 'forms', 'meet', 'drive'],
60
+ creator: ['cms', 'drive', 'shortener', 'forms', 'assistant'],
61
+ developer: ['git', 'ai', 'docs', 'tasks', 'tools'],
62
+ operations: ['inventory', 'finance', 'contacts', 'forms', 'tasks'],
63
+ } as const satisfies Record<OnboardingPersona, readonly string[]>;
64
+
65
+ export const GOAL_RECOMMENDATIONS = {
66
+ focus: ['tasks', 'calendar', 'mail', 'track', 'assistant'],
67
+ collaborate: ['chat', 'meet', 'drive', 'tasks', 'forms'],
68
+ operate: ['finance', 'inventory', 'contacts', 'pay', 'storefront'],
69
+ learn: ['learn', 'teach', 'rewise', 'mind', 'docs'],
70
+ build: ['ai', 'git', 'cms', 'nova', 'tools'],
71
+ } as const satisfies Record<OnboardingGoal, readonly string[]>;
72
+
73
+ const MISSIONS = [
74
+ ['platform', 'focus', 'reversible', ['overview', 'apps', 'next_move']],
75
+ ['calendar', 'focus', 'reversible', ['schedule', 'timebox', 'review']],
76
+ [
77
+ 'chat',
78
+ 'collaborate',
79
+ 'confirmation_required',
80
+ ['spaces', 'message', 'follow_up'],
81
+ ],
82
+ ['cms', 'build', 'confirmation_required', ['content', 'preview', 'publish']],
83
+ [
84
+ 'drive',
85
+ 'collaborate',
86
+ 'confirmation_required',
87
+ ['organize', 'share', 'find'],
88
+ ],
89
+ ['git', 'build', 'preview', ['repositories', 'browse', 'search']],
90
+ ['apps', 'focus', 'reversible', ['discover', 'filter', 'launch']],
91
+ ['docs', 'learn', 'preview', ['browse', 'search', 'reference']],
92
+ [
93
+ 'finance',
94
+ 'operate',
95
+ 'confirmation_required',
96
+ ['wallets', 'transactions', 'reports'],
97
+ ],
98
+ ['hive', 'build', 'preview', ['world', 'simulate', 'observe']],
99
+ [
100
+ 'inventory',
101
+ 'operate',
102
+ 'confirmation_required',
103
+ ['catalog', 'stock', 'movement'],
104
+ ],
105
+ [
106
+ 'storefront',
107
+ 'operate',
108
+ 'confirmation_required',
109
+ ['store', 'preview', 'checkout'],
110
+ ],
111
+ ['learn', 'learn', 'reversible', ['courses', 'study', 'progress']],
112
+ ['mail', 'focus', 'confirmation_required', ['inbox', 'compose', 'follow_up']],
113
+ ['meet', 'collaborate', 'confirmation_required', ['room', 'invite', 'meet']],
114
+ ['mind', 'learn', 'reversible', ['canvas', 'connect', 'organize']],
115
+ [
116
+ 'ai',
117
+ 'build',
118
+ 'confirmation_required',
119
+ ['playground', 'models', 'evaluate'],
120
+ ],
121
+ ['nova', 'build', 'confirmation_required', ['challenge', 'prompt', 'submit']],
122
+ ['tools', 'build', 'reversible', ['choose', 'configure', 'generate']],
123
+ ['rewise', 'learn', 'reversible', ['material', 'practice', 'review']],
124
+ ['shortener', 'build', 'reversible', ['link', 'customize', 'measure']],
125
+ ['tasks', 'focus', 'reversible', ['capture', 'plan', 'complete']],
126
+ ['teach', 'learn', 'confirmation_required', ['course', 'lesson', 'share']],
127
+ ['pay', 'operate', 'confirmation_required', ['request', 'review', 'pay']],
128
+ [
129
+ 'contacts',
130
+ 'operate',
131
+ 'confirmation_required',
132
+ ['people', 'fields', 'follow_up'],
133
+ ],
134
+ [
135
+ 'forms',
136
+ 'collaborate',
137
+ 'confirmation_required',
138
+ ['build', 'preview', 'share'],
139
+ ],
140
+ ['track', 'focus', 'reversible', ['timer', 'session', 'insights']],
141
+ ] as const satisfies readonly (readonly [
142
+ OnboardingAppSlug,
143
+ OnboardingGoal,
144
+ OnboardingActionSafety,
145
+ readonly [string, string, string],
146
+ ])[];
147
+
148
+ export const ONBOARDING_MISSIONS: readonly OnboardingMission[] = MISSIONS.map(
149
+ ([appSlug, goal, safety, capabilities]) => ({
150
+ appSlug,
151
+ capabilities,
152
+ goal,
153
+ safety,
154
+ })
155
+ );
156
+
157
+ export function getOnboardingMission(appSlug: string) {
158
+ return ONBOARDING_MISSIONS.find((mission) => mission.appSlug === appSlug);
159
+ }
160
+
161
+ export function recommendOnboardingApps(
162
+ preferences: OnboardingJourneyPreferences,
163
+ limit = 8
164
+ ): OnboardingAppSlug[] {
165
+ const ranked = new Map<string, number>();
166
+ const add = (slug: string, score: number) =>
167
+ ranked.set(slug, (ranked.get(slug) ?? 0) + score);
168
+
169
+ if (preferences.persona) {
170
+ PERSONA_RECOMMENDATIONS[preferences.persona].forEach((slug, index) => {
171
+ add(slug, 20 - index);
172
+ });
173
+ }
174
+
175
+ preferences.goals.forEach((goal) => {
176
+ GOAL_RECOMMENDATIONS[goal].forEach((slug, index) => {
177
+ add(slug, 12 - index);
178
+ });
179
+ });
180
+
181
+ const launchable = new Set(LAUNCHABLE_APPS.map((app) => app.slug));
182
+ return [...ranked.entries()]
183
+ .filter(([slug]) => launchable.has(slug as OnboardingAppSlug))
184
+ .sort((left, right) => right[1] - left[1])
185
+ .slice(0, limit)
186
+ .map(([slug]) => slug as OnboardingAppSlug);
187
+ }
188
+
189
+ export function createDefaultOnboardingProgress(): OnboardingJourneyProgress {
190
+ return {
191
+ completedMissionIds: [],
192
+ dismissedAt: null,
193
+ goals: [],
194
+ mode: 'standard',
195
+ persona: null,
196
+ replayApp: null,
197
+ revision: ONBOARDING_JOURNEY_REVISION,
198
+ };
199
+ }
@@ -19,6 +19,7 @@ import {
19
19
  FileKey2,
20
20
  FileText,
21
21
  FlaskConical,
22
+ GitBranch,
22
23
  HandCoins,
23
24
  HardDrive,
24
25
  House,
@@ -131,6 +132,16 @@ export const permissionGroups = ({
131
132
  disableOnProduction: false,
132
133
  disabled: false,
133
134
  },
135
+ {
136
+ id: 'manage_git_repositories' as PermissionId,
137
+ icon: <GitBranch />,
138
+ title: t('ws-roles.manage_git_repositories'),
139
+ description: t(
140
+ 'ws-roles.manage_git_repositories_description'
141
+ ),
142
+ disableOnProduction: false,
143
+ disabled: false,
144
+ },
134
145
  {
135
146
  id: 'manage_mobile_deployment_vault' as PermissionId,
136
147
  icon: <FileKey2 />,
@@ -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.29.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.32.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.29.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.32.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
@@ -17,6 +17,7 @@ export const TUTURUUU_PORTLESS_APP_ORIGINS = {
17
17
  external: `https://external.${TUTURUUU_PORTLESS_ROOT_HOST}`,
18
18
  finance: `https://finance.${TUTURUUU_PORTLESS_ROOT_HOST}`,
19
19
  forms: `https://forms.${TUTURUUU_PORTLESS_ROOT_HOST}`,
20
+ git: `https://git.${TUTURUUU_PORTLESS_ROOT_HOST}`,
20
21
  hive: `https://hive.${TUTURUUU_PORTLESS_ROOT_HOST}`,
21
22
  'hive-realtime': `https://realtime.hive.${TUTURUUU_PORTLESS_ROOT_HOST}`,
22
23
  infra: `https://infra.${TUTURUUU_PORTLESS_ROOT_HOST}`,
@@ -689,19 +689,32 @@ async function resolveWorkspaceIdForPermissions({
689
689
  return normalizeWorkspaceId(wsId, authorizationClient);
690
690
  }
691
691
 
692
- const { data: workspace, error } = await authorizationClient
693
- .from('workspaces')
694
- .select('id, workspace_members!inner(user_id, type)')
695
- .eq('personal', true)
696
- .eq('workspace_members.user_id', principal.id)
697
- .eq('workspace_members.type', 'MEMBER')
698
- .maybeSingle();
692
+ const { data: creatorWorkspace, error: creatorWorkspaceError } =
693
+ await authorizationClient
694
+ .from('workspaces')
695
+ .select('id')
696
+ .eq('personal', true)
697
+ .eq('creator_id', principal.id)
698
+ .maybeSingle();
699
+
700
+ if (!creatorWorkspaceError && creatorWorkspace?.id) {
701
+ return creatorWorkspace.id;
702
+ }
699
703
 
700
- if (error || !workspace?.id) {
704
+ const { data: memberWorkspace, error: memberWorkspaceError } =
705
+ await authorizationClient
706
+ .from('workspaces')
707
+ .select('id, workspace_members!inner(user_id, type)')
708
+ .eq('personal', true)
709
+ .eq('workspace_members.user_id', principal.id)
710
+ .eq('workspace_members.type', 'MEMBER')
711
+ .maybeSingle();
712
+
713
+ if (memberWorkspaceError || !memberWorkspace?.id) {
701
714
  throw new Error('Personal workspace not found');
702
715
  }
703
716
 
704
- return workspace.id;
717
+ return memberWorkspace.id;
705
718
  }
706
719
 
707
720
  /**
@@ -761,7 +774,7 @@ async function getPermissionsImpl({
761
774
  let resolvedWorkspaceId: string;
762
775
  try {
763
776
  resolvedWorkspaceId = await resolveWorkspaceIdForPermissions({
764
- authorizationClient: authorizationClient as TypedSupabaseClient,
777
+ authorizationClient: sbAdmin,
765
778
  principal,
766
779
  wsId,
767
780
  });
@@ -769,21 +782,39 @@ async function getPermissionsImpl({
769
782
  return null;
770
783
  }
771
784
 
772
- const membership = await verifyWorkspaceMembershipType({
773
- wsId: resolvedWorkspaceId,
774
- userId,
775
- supabase: authorizationClient as TypedSupabaseClient,
776
- requiredType: 'ANY',
777
- });
785
+ const [membership, workspaceRes] = await Promise.all([
786
+ verifyWorkspaceMembershipType({
787
+ wsId: resolvedWorkspaceId,
788
+ userId,
789
+ supabase: authorizationClient as TypedSupabaseClient,
790
+ requiredType: 'ANY',
791
+ }),
792
+ sbAdmin
793
+ .from('workspaces')
794
+ .select('creator_id')
795
+ .eq('id', resolvedWorkspaceId)
796
+ .single(),
797
+ ]);
778
798
 
779
- if (!membership.ok) {
799
+ const { data: workspaceData, error: workspaceError } = workspaceRes;
800
+
801
+ if (workspaceError || !workspaceData) {
802
+ console.info('Workspace not found in getPermissions', resolvedWorkspaceId);
780
803
  return null;
781
804
  }
782
805
 
783
- const membershipType = membership.membershipType ?? 'MEMBER';
806
+ const isCreator = workspaceData.creator_id === userId;
807
+
808
+ if (!isCreator && !membership.ok) {
809
+ return null;
810
+ }
811
+
812
+ const membershipType = isCreator
813
+ ? 'MEMBER'
814
+ : (membership.membershipType ?? 'MEMBER');
784
815
 
785
816
  const permissionsQuery =
786
- membershipType === 'MEMBER'
817
+ membershipType === 'MEMBER' && !isCreator
787
818
  ? sbAdmin
788
819
  .from('workspace_role_members')
789
820
  .select(
@@ -794,40 +825,26 @@ async function getPermissionsImpl({
794
825
  .eq('workspace_roles.workspace_role_permissions.enabled', true)
795
826
  : Promise.resolve({ data: [], error: null });
796
827
 
797
- const workspaceQuery = sbAdmin
798
- .from('workspaces')
799
- .select('creator_id')
800
- .eq('id', resolvedWorkspaceId)
801
- .single();
828
+ const defaultQuery = isCreator
829
+ ? Promise.resolve({ data: [], error: null })
830
+ : sbAdmin
831
+ .from('workspace_default_permissions')
832
+ .select('permission')
833
+ .eq('ws_id', resolvedWorkspaceId)
834
+ .eq('member_type', membershipType)
835
+ .eq('enabled', true);
802
836
 
803
- const defaultQuery = sbAdmin
804
- .from('workspace_default_permissions')
805
- .select('permission')
806
- .eq('ws_id', resolvedWorkspaceId)
807
- .eq('member_type', membershipType)
808
- .eq('enabled', true);
809
-
810
- const [permissionsRes, workspaceRes, defaultRes] = await Promise.all([
837
+ const [permissionsRes, defaultRes] = await Promise.all([
811
838
  permissionsQuery,
812
- workspaceQuery,
813
839
  defaultQuery,
814
840
  ]);
815
841
 
816
842
  const { data: permissionsData, error: permissionsError } = permissionsRes;
817
- const { data: workspaceData, error: workspaceError } = workspaceRes;
818
843
  const { data: defaultData, error: defaultError } = defaultRes;
819
844
 
820
- if (!workspaceData) {
821
- console.info('Workspace not found in getPermissions', resolvedWorkspaceId);
822
- return null;
823
- }
824
-
825
845
  if (permissionsError) return null;
826
- if (workspaceError) return null;
827
846
  if (defaultError) return null;
828
847
 
829
- const isCreator =
830
- membershipType === 'MEMBER' && workspaceData.creator_id === userId;
831
848
  // if (DEV_MODE) {
832
849
  // console.log('--------------------');
833
850
  // console.log('Is creator', isCreator);