@tuturuuu/utils 0.27.0 → 0.28.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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.28.0](https://github.com/tutur3u/platform/compare/utils-v0.27.1...utils-v0.28.0) (2026-09-07)
4
+
5
+
6
+ ### Features
7
+
8
+ * **calendar:** add installable Tasks and Calendar with bounded caching ([#5231](https://github.com/tutur3u/platform/issues/5231)) ([7078824](https://github.com/tutur3u/platform/commit/70788244874d811da3e1ce3a9af2c9389739a8c3))
9
+ * **colab:** launch multiplayer prompt workshops on Cloudflare ([7a4a3f9](https://github.com/tutur3u/platform/commit/7a4a3f9b4a12a0d7439cdb296398a21c0cd8939f))
10
+ * **meet:** host on Cloudflare and restrict meeting creation ([#5223](https://github.com/tutur3u/platform/issues/5223)) ([dff093b](https://github.com/tutur3u/platform/commit/dff093b4b6ade986ffd54f3c756d08589e47ad8e))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **calendar:** harden PWA lifecycle and cache retention ([14565c0](https://github.com/tutur3u/platform/commit/14565c0106e74b947e152fd8bb787de7f70bc54d))
16
+ * **calendar:** recover failed syncs and redesign calendar views ([7d22948](https://github.com/tutur3u/platform/commit/7d22948095a29d236f698d42dde362fc38311dd0))
17
+ * **calendar:** recover failed syncs and redesign calendar views ([#5230](https://github.com/tutur3u/platform/issues/5230)) ([e52605d](https://github.com/tutur3u/platform/commit/e52605d671f921b1564746afcdedf2478deee78e))
18
+ * **meet:** restrict meeting hosts and restore reliable call media ([2a8a533](https://github.com/tutur3u/platform/commit/2a8a53355181fc8d429653506f7c845c02cf7ae9))
19
+
20
+ ## [0.27.1](https://github.com/tutur3u/platform/compare/utils-v0.27.0...utils-v0.27.1) (2026-09-04)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * **ai:** type workspace resolution failures ([10b9141](https://github.com/tutur3u/platform/commit/10b9141999894f25f7c4e0ca81a9d7e75dbfb2c1))
26
+ * **rewise:** bind AI work to selected workspace ([#5208](https://github.com/tutur3u/platform/issues/5208)) ([5f1d8f3](https://github.com/tutur3u/platform/commit/5f1d8f3544e87e78b6555cd679c36f2878d0dab9))
27
+
3
28
  ## [0.27.0](https://github.com/tutur3u/platform/compare/utils-v0.26.0...utils-v0.27.0) (2026-08-27)
4
29
 
5
30
 
package/biome.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "root": false,
3
- "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
3
+ "$schema": "https://biomejs.dev/schemas/2.5.12/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.27.0",
4
+ "version": "0.28.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -17,43 +17,42 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@dnd-kit/core": "^6.3.1",
20
- "@noble/ciphers": "^2.3.0",
21
- "@noble/hashes": "^2.3.0",
22
- "@tanstack/react-query": "^5.102.1",
23
- "@tiptap/core": "3.30.2",
24
- "@tiptap/react": "3.30.2",
20
+ "@noble/ciphers": "^2.4.0",
21
+ "@noble/hashes": "^2.4.0",
22
+ "@tanstack/react-query": "^5.102.8",
23
+ "@tiptap/core": "3.31.2",
24
+ "@tiptap/react": "3.31.2",
25
25
  "@tuturuuu/google": "0.1.0",
26
26
  "@tuturuuu/icons": "0.1.0",
27
- "@tuturuuu/internal-api": "0.33.0",
27
+ "@tuturuuu/internal-api": "0.34.0",
28
28
  "@tuturuuu/supabase": "0.5.1",
29
29
  "@upstash/ratelimit": "^2.0.8",
30
- "@upstash/redis": "^1.38.2",
31
- "clsx": "^2.1.1",
30
+ "@upstash/redis": "^1.38.3",
31
+ "cn": "^0.2.5",
32
32
  "date-fns": "^4.4.0",
33
33
  "dayjs": "^1.11.23",
34
34
  "diff": "^9.0.0",
35
35
  "effect": "^3.22.1",
36
36
  "moment": "^2.30.1",
37
- "next": "^16.3.2",
37
+ "next": "^16.3.4",
38
38
  "prosemirror-model": "^1.25.11",
39
39
  "react": "^19.2.8",
40
40
  "react-dom": "^19.2.8",
41
- "tailwind-merge": "^3.6.0",
42
41
  "uuid": "^14.0.2",
43
42
  "y-prosemirror": "^1.3.7",
44
43
  "yjs": "^13.6.32",
45
- "zod": "^4.4.3"
44
+ "zod": "^4.5.4"
46
45
  },
47
46
  "devDependencies": {
48
- "@tuturuuu/types": "0.28.0",
47
+ "@tuturuuu/types": "0.29.0",
49
48
  "@tuturuuu/typescript-config": "0.1.2",
50
49
  "@types/diff": "^8.0.0",
51
- "@types/node": "^26.2.0",
50
+ "@types/node": "^26.4.1",
52
51
  "@types/react": "^19.2.18",
53
- "@vitejs/plugin-react": "^6.1.0",
52
+ "@vitejs/plugin-react": "^6.1.1",
54
53
  "typescript": "7.0.2",
55
54
  "vite": "^8.2.2",
56
- "vitest": "^4.1.11"
55
+ "vitest": "^5.0.0"
57
56
  },
58
57
  "exports": {
59
58
  "./timezones": "./src/timezones.json",
@@ -77,5 +76,5 @@
77
76
  "./encryption": "./src/encryption/index.ts",
78
77
  "./finance": "./src/finance/index.ts"
79
78
  },
80
- "packageManager": "bun@1.4.0"
79
+ "packageManager": "bun@1.4.1"
81
80
  }
@@ -292,6 +292,7 @@ describe('Portless app origin registry', () => {
292
292
  'https://calendar.tuturuuu.localhost',
293
293
  'https://chat.tuturuuu.localhost',
294
294
  'https://cms.tuturuuu.localhost',
295
+ 'https://colab.tuturuuu.localhost',
295
296
  'https://contacts.tuturuuu.localhost',
296
297
  'https://docs.tuturuuu.localhost',
297
298
  'https://drive.tuturuuu.localhost',
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  capitalize,
4
+ cn,
4
5
  formatBytes,
5
6
  formatCurrency,
6
7
  formatDuration,
@@ -10,6 +11,48 @@ import {
10
11
  } from '../format';
11
12
 
12
13
  describe('Format Utilities', () => {
14
+ describe('cn', () => {
15
+ it('joins conditional class values with clsx-compatible semantics', () => {
16
+ expect(
17
+ cn('flex', false, null, undefined, [
18
+ 'items-center',
19
+ { 'opacity-50': true, hidden: false },
20
+ ])
21
+ ).toBe('flex items-center opacity-50');
22
+ });
23
+
24
+ it('resolves conflicting Tailwind utilities using the last value', () => {
25
+ expect(cn('px-2 py-1', 'px-4', 'py-3')).toBe('px-4 py-3');
26
+ });
27
+
28
+ it('keeps independent responsive and state variants', () => {
29
+ expect(
30
+ cn(
31
+ 'text-sm hover:text-base md:text-lg',
32
+ 'text-xl hover:text-2xl md:text-3xl'
33
+ )
34
+ ).toBe('text-xl hover:text-2xl md:text-3xl');
35
+ });
36
+
37
+ it('preserves non-conflicting variants from separate inputs', () => {
38
+ expect(cn('text-sm hover:text-base', 'md:text-lg')).toBe(
39
+ 'text-sm hover:text-base md:text-lg'
40
+ );
41
+ });
42
+
43
+ it('resolves arbitrary values and important modifiers', () => {
44
+ expect(cn('w-[10px] w-[var(--panel-width)]', '!p-4 p-2')).toBe(
45
+ 'w-[var(--panel-width)] !p-4 p-2'
46
+ );
47
+ });
48
+
49
+ it('preserves non-Tailwind application classes', () => {
50
+ expect(cn('app-shell', 'feature-card', { selected: true })).toBe(
51
+ 'app-shell feature-card selected'
52
+ );
53
+ });
54
+ });
55
+
13
56
  describe('capitalize', () => {
14
57
  it('capitalizes first letter of a word', () => {
15
58
  expect(capitalize('hello')).toBe('Hello');
@@ -17,6 +17,9 @@ import {
17
17
  isPersonalWorkspace,
18
18
  isWorkspaceUuidLiteral,
19
19
  normalizeWorkspaceId,
20
+ WorkspaceAuthError,
21
+ WorkspaceNotFoundError,
22
+ WorkspaceResolutionError,
20
23
  } from '../workspace-helper';
21
24
 
22
25
  describe('isWorkspaceUuidLiteral', () => {
@@ -410,6 +413,41 @@ describe('normalizeWorkspaceId', () => {
410
413
  expect(query.eq).toHaveBeenCalledWith('workspace_members.type', 'MEMBER');
411
414
  });
412
415
 
416
+ it('uses a typed error when personal workspace authentication is missing', async () => {
417
+ mockCreateClient.mockResolvedValue({
418
+ auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
419
+ });
420
+
421
+ await expect(normalizeWorkspaceId('personal')).rejects.toBeInstanceOf(
422
+ WorkspaceAuthError
423
+ );
424
+ });
425
+
426
+ it.each([
427
+ [new Error('database unavailable'), WorkspaceResolutionError],
428
+ [null, WorkspaceNotFoundError],
429
+ ])('distinguishes personal lookup failures', async (error, ErrorType) => {
430
+ const query = {
431
+ eq: vi.fn(),
432
+ maybeSingle: vi.fn().mockResolvedValue({ data: null, error }),
433
+ select: vi.fn(),
434
+ };
435
+ query.select.mockReturnValue(query);
436
+ query.eq.mockReturnValue(query);
437
+ mockCreateClient.mockResolvedValue({
438
+ auth: {
439
+ getUser: vi.fn().mockResolvedValue({
440
+ data: { user: { id: 'user-1' } },
441
+ }),
442
+ },
443
+ from: vi.fn(() => query),
444
+ });
445
+
446
+ await expect(normalizeWorkspaceId('personal')).rejects.toBeInstanceOf(
447
+ ErrorType
448
+ );
449
+ });
450
+
413
451
  it('resolves handle via admin fallback when request-scoped lookup cannot see workspace', async () => {
414
452
  const requestScopedQuery = {
415
453
  select: vi.fn(),
@@ -0,0 +1,2 @@
1
+ // Shared wire-protocol constants; safe to import in browser bundles.
2
+ export const AI_TEMP_AUTH_HEADER = 'x-tuturuuu-ai-temp-auth';
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { AI_TEMP_AUTH_HEADER } from './ai-temp-auth-constants';
2
3
  import { getUpstashRestRedisClient } from './upstash-rest';
3
4
 
4
- export const AI_TEMP_AUTH_HEADER = 'x-tuturuuu-ai-temp-auth';
5
+ export { AI_TEMP_AUTH_HEADER } from './ai-temp-auth-constants';
5
6
  export const AI_TEMP_AUTH_TTL_SECONDS = 60;
6
7
  export const AI_CREDIT_SNAPSHOT_TTL_SECONDS = 15;
7
8
 
@@ -15,7 +15,16 @@ export function isAllDayEvent(
15
15
  const durationMs = end.diff(start, 'millisecond');
16
16
  const isMultipleOf24Hours = durationMs % (24 * 60 * 60 * 1000) === 0;
17
17
 
18
- return durationMs > 0 && isMultipleOf24Hours;
18
+ const spansLocalMidnights =
19
+ start.startOf('day').valueOf() === start.valueOf() &&
20
+ end.startOf('day').valueOf() === end.valueOf();
21
+ const wallDuration =
22
+ durationMs + (end.utcOffset() - start.utcOffset()) * 60_000;
23
+ return (
24
+ durationMs > 0 &&
25
+ (isMultipleOf24Hours ||
26
+ (spansLocalMidnights && wallDuration % (24 * 60 * 60 * 1000) === 0))
27
+ );
19
28
  }
20
29
 
21
30
  // Helper function to convert Google Calendar all-day events to proper timezone
package/src/format.ts CHANGED
@@ -1,12 +1,7 @@
1
- import { type ClassValue, clsx } from 'clsx';
2
- import { twMerge } from 'tailwind-merge';
1
+ export { cn } from 'cn';
3
2
 
4
3
  import { getCurrencyLocale as getCurrencyLocaleFromConfig } from './currencies';
5
4
 
6
- export function cn(...inputs: ClassValue[]): string {
7
- return twMerge(clsx(inputs));
8
- }
9
-
10
5
  export const capitalize = (s?: string | null): string => {
11
6
  if (!s) return '';
12
7
  if (s.length === 0) return s;
@@ -4,6 +4,7 @@ import {
4
4
  } from './portless';
5
5
 
6
6
  export const PRODUCTION_INTERNAL_APP_DOMAINS = [
7
+ { name: 'colab', url: 'https://colab.tuturuuu.com' },
7
8
  {
8
9
  name: 'ai',
9
10
  url: 'https://ai.tuturuuu.com',
@@ -131,6 +132,7 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
131
132
  ] as const;
132
133
 
133
134
  export const PORTLESS_INTERNAL_APP_DOMAINS = [
135
+ { name: 'colab', url: getTuturuuuPortlessAppOrigin('colab') },
134
136
  {
135
137
  name: 'ai',
136
138
  url: getTuturuuuPortlessAppOrigin('ai'),
@@ -250,6 +252,7 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
250
252
  ] as const;
251
253
 
252
254
  export const LOCALHOST_INTERNAL_APP_DOMAINS = [
255
+ { name: 'colab', url: 'http://localhost:8795' },
253
256
  {
254
257
  name: 'ai',
255
258
  url: 'http://localhost:7829',
@@ -0,0 +1,40 @@
1
+ /** Shared guards for document/window keyboard commands. */
2
+ export function isEditableShortcutTarget(target: EventTarget | null): boolean {
3
+ return (
4
+ typeof Element !== 'undefined' &&
5
+ target instanceof Element &&
6
+ Boolean(
7
+ target.closest(
8
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="combobox"]'
9
+ )
10
+ )
11
+ );
12
+ }
13
+
14
+ export function isShortcutEventIgnored(
15
+ event: Pick<KeyboardEvent, 'defaultPrevented' | 'isComposing' | 'repeat'>
16
+ ): boolean {
17
+ return event.defaultPrevented || event.isComposing || event.repeat;
18
+ }
19
+
20
+ export function isPageShortcutBlocked(event: KeyboardEvent): boolean {
21
+ if (isShortcutEventIgnored(event) || isEditableShortcutTarget(event.target))
22
+ return true;
23
+ const target = event.target;
24
+ if (
25
+ typeof Element !== 'undefined' &&
26
+ target instanceof Element &&
27
+ target.closest(
28
+ '[role="menu"], [role="listbox"], [role="dialog"], [role="alertdialog"]'
29
+ )
30
+ )
31
+ return true;
32
+ return (
33
+ typeof document !== 'undefined' &&
34
+ Boolean(
35
+ document.querySelector(
36
+ '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]'
37
+ )
38
+ )
39
+ );
40
+ }
@@ -387,6 +387,18 @@ export const LAUNCHABLE_APPS = [
387
387
  title: 'Track',
388
388
  workspacePathResolver: workspaceRootPath,
389
389
  },
390
+ {
391
+ aliases: ['Prompt engineering', 'Team workshops', 'Multiplayer learning'],
392
+ appRoot: 'apps/colab',
393
+ category: 'learning',
394
+ defaultPath: '/',
395
+ localhostOrigin: 'http://localhost:8795',
396
+ packageName: '@tuturuuu/colab',
397
+ portlessApp: 'colab',
398
+ productionUrl: 'https://colab.tuturuuu.com',
399
+ slug: 'colab',
400
+ title: 'Colab',
401
+ },
390
402
  ] as const satisfies readonly LaunchableApp[];
391
403
 
392
404
  export type LaunchableAppSlug = (typeof LAUNCHABLE_APPS)[number]['slug'];
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { canCreateOnlineMeeting } from './meet-creation-policy';
3
+
4
+ describe('online meeting creator domain', () => {
5
+ it.each(['host@tuturuuu.com', 'Host@TUTURUUU.COM'])('allows %s', (email) => {
6
+ expect(canCreateOnlineMeeting(email)).toBe(true);
7
+ });
8
+ it.each([
9
+ null,
10
+ undefined,
11
+ '',
12
+ '@tuturuuu.com',
13
+ 'host@gmail.com',
14
+ 'host@sub.tuturuuu.com',
15
+ 'host@tuturuuu.com.evil.test',
16
+ 'host@evil.test@tuturuuu.com',
17
+ ' host@tuturuuu.com',
18
+ ])('rejects %s', (email) => {
19
+ expect(canCreateOnlineMeeting(email)).toBe(false);
20
+ });
21
+ });
@@ -0,0 +1,6 @@
1
+ /** Apply only to an authenticated account email, never editable profile metadata. */
2
+ export function canCreateOnlineMeeting(
3
+ email: string | null | undefined
4
+ ): boolean {
5
+ return typeof email === 'string' && /^[^\s@]+@tuturuuu\.com$/iu.test(email);
6
+ }
package/src/onboarding.ts CHANGED
@@ -71,6 +71,7 @@ export const GOAL_RECOMMENDATIONS = {
71
71
  } as const satisfies Record<OnboardingGoal, readonly string[]>;
72
72
 
73
73
  const MISSIONS = [
74
+ ['colab', 'learn', 'reversible', ['prompt', 'simulate', 'review']],
74
75
  ['platform', 'focus', 'reversible', ['overview', 'apps', 'next_move']],
75
76
  ['calendar', 'focus', 'reversible', ['schedule', 'timebox', 'review']],
76
77
  [
@@ -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.43.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.46.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.43.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.46.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
@@ -11,6 +11,7 @@ export const TUTURUUU_PORTLESS_APP_ORIGINS = {
11
11
  calendar: `https://calendar.${TUTURUUU_PORTLESS_ROOT_HOST}`,
12
12
  chat: `https://chat.${TUTURUUU_PORTLESS_ROOT_HOST}`,
13
13
  cms: `https://cms.${TUTURUUU_PORTLESS_ROOT_HOST}`,
14
+ colab: `https://colab.${TUTURUUU_PORTLESS_ROOT_HOST}`,
14
15
  contacts: `https://contacts.${TUTURUUU_PORTLESS_ROOT_HOST}`,
15
16
  docs: `https://docs.${TUTURUUU_PORTLESS_ROOT_HOST}`,
16
17
  drive: `https://drive.${TUTURUUU_PORTLESS_ROOT_HOST}`,
@@ -28,6 +28,20 @@ export class WorkspaceAuthError extends Error {
28
28
  }
29
29
  }
30
30
 
31
+ export class WorkspaceNotFoundError extends Error {
32
+ constructor(message = 'Workspace not found') {
33
+ super(message);
34
+ this.name = 'WorkspaceNotFoundError';
35
+ }
36
+ }
37
+
38
+ export class WorkspaceResolutionError extends Error {
39
+ constructor(message = 'Workspace resolution failed') {
40
+ super(message);
41
+ this.name = 'WorkspaceResolutionError';
42
+ }
43
+ }
44
+
31
45
  export class WorkspaceAccessError extends Error {
32
46
  constructor(message = 'Workspace access denied') {
33
47
  super(message);
@@ -1096,7 +1110,7 @@ export async function normalizeWorkspaceId(
1096
1110
  const principal = await resolveAuthenticatedPrincipal(sb);
1097
1111
 
1098
1112
  if (!principal) {
1099
- throw new Error('User not authenticated');
1113
+ throw new WorkspaceAuthError();
1100
1114
  }
1101
1115
 
1102
1116
  const userId = principal.id;
@@ -1109,8 +1123,11 @@ export async function normalizeWorkspaceId(
1109
1123
  .eq('workspace_members.type', 'MEMBER')
1110
1124
  .maybeSingle();
1111
1125
 
1112
- if (error || !workspace) {
1113
- throw new Error('Personal workspace not found');
1126
+ if (error) {
1127
+ throw new WorkspaceResolutionError('Personal workspace lookup failed');
1128
+ }
1129
+ if (!workspace) {
1130
+ throw new WorkspaceNotFoundError('Personal workspace not found');
1114
1131
  }
1115
1132
 
1116
1133
  return workspace.id;