@tuturuuu/utils 0.15.0 → 0.16.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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.16.0](https://github.com/tutur3u/platform/compare/utils-v0.15.1...utils-v0.16.0) (2026-07-18)
4
+
5
+
6
+ ### Features
7
+
8
+ * **seo:** improve discovery metadata ([a574409](https://github.com/tutur3u/platform/commit/a57440984df45086c4989db07b02d402dd93c828))
9
+ * **seo:** improve discovery metadata ([8e2420d](https://github.com/tutur3u/platform/commit/8e2420d49e7b94c462d92222ea8f239ab92564f6))
10
+ * **seo:** improve page metadata ([0c621cb](https://github.com/tutur3u/platform/commit/0c621cb7fc1253b0a11ea21992503274052c6be0))
11
+ * **seo:** standardize app metadata ([6523d91](https://github.com/tutur3u/platform/commit/6523d91fedf38e19804d10ea3b82890db180bc6f))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **mobile:** preserve bearer auth across satellites ([f890170](https://github.com/tutur3u/platform/commit/f89017044cf3aaaa6a1b15c31c64a81d75cfdab2))
17
+
18
+ ## [0.15.1](https://github.com/tutur3u/platform/compare/utils-v0.15.0...utils-v0.15.1) (2026-07-13)
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * **contacts:** restore group post mutations ([fe621e4](https://github.com/tutur3u/platform/commit/fe621e4d7e4126f705082cc3e815aafdeebe535a))
24
+ * **security:** prevent clickjacking across apps ([c8ed36e](https://github.com/tutur3u/platform/commit/c8ed36e708c08ebe3b17ee7f6249513644108c48))
25
+
3
26
  ## [0.15.0](https://github.com/tutur3u/platform/compare/utils-v0.14.0...utils-v0.15.0) (2026-07-11)
4
27
 
5
28
 
package/biome.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "root": false,
3
- "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
3
+ "$schema": "https://biomejs.dev/schemas/2.5.4/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.15.0",
4
+ "version": "0.16.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -20,11 +20,11 @@
20
20
  "@noble/ciphers": "^2.2.0",
21
21
  "@noble/hashes": "^2.2.0",
22
22
  "@tanstack/react-query": "^5.101.2",
23
- "@tiptap/core": "3.27.3",
24
- "@tiptap/react": "3.27.3",
23
+ "@tiptap/core": "3.27.4",
24
+ "@tiptap/react": "3.27.4",
25
25
  "@tuturuuu/google": "0.0.3",
26
26
  "@tuturuuu/icons": "0.0.6",
27
- "@tuturuuu/internal-api": "0.17.0",
27
+ "@tuturuuu/internal-api": "0.19.0",
28
28
  "@tuturuuu/supabase": "0.4.0",
29
29
  "@upstash/ratelimit": "^2.0.8",
30
30
  "@upstash/redis": "^1.38.0",
@@ -32,9 +32,9 @@
32
32
  "date-fns": "^4.4.0",
33
33
  "dayjs": "^1.11.20",
34
34
  "diff": "^9.0.0",
35
- "effect": "^3.21.4",
35
+ "effect": "^3.22.0",
36
36
  "moment": "^2.30.1",
37
- "next": "^16.3.0-preview.5",
37
+ "next": "^16.3.0-preview.6",
38
38
  "prosemirror-model": "^1.25.11",
39
39
  "react": "^19.2.7",
40
40
  "react-dom": "^19.2.7",
@@ -45,7 +45,7 @@
45
45
  "zod": "^4.4.3"
46
46
  },
47
47
  "devDependencies": {
48
- "@tuturuuu/types": "0.16.0",
48
+ "@tuturuuu/types": "0.18.0",
49
49
  "@tuturuuu/typescript-config": "0.1.1",
50
50
  "@types/diff": "^8.0.0",
51
51
  "@types/node": "^26.1.1",
@@ -62,6 +62,7 @@
62
62
  "./hooks/*": "./src/hooks/*.ts",
63
63
  "./*": "./src/*.ts",
64
64
  "./common/scan": "./src/common/scan.tsx",
65
+ "./common/metadata": "./src/common/metadata.ts",
65
66
  "./common/nextjs": "./src/common/nextjs.tsx",
66
67
  "./permissions": "./src/permissions.tsx",
67
68
  "./editor": "./src/editor/index.ts",
@@ -1663,6 +1663,23 @@ describe('guardApiProxyRequest', () => {
1663
1663
  ).toBe(false);
1664
1664
  });
1665
1665
 
1666
+ it('detects bearer credentials that route handlers can authenticate', async () => {
1667
+ const { hasAuthenticatedBearerToken } = await import(
1668
+ '../api-proxy-guard.js'
1669
+ );
1670
+
1671
+ expect(
1672
+ hasAuthenticatedBearerToken(
1673
+ new Headers({ authorization: 'Bearer header.payload.signature' })
1674
+ )
1675
+ ).toBe(true);
1676
+ expect(
1677
+ hasAuthenticatedBearerToken(
1678
+ new Headers({ authorization: 'Bearer malformed-token' })
1679
+ )
1680
+ ).toBe(false);
1681
+ });
1682
+
1666
1683
  it('enforces anonymous IP blocks for cookie-looking browser requests', async () => {
1667
1684
  vi.stubEnv('NODE_ENV', 'production');
1668
1685
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -1735,35 +1752,35 @@ describe('guardApiProxyRequest', () => {
1735
1752
  );
1736
1753
  });
1737
1754
 
1738
- it.each([
1739
- '/api/v1/auth/otp/send',
1740
- '/api/v1/auth/mobile/send-otp',
1741
- ])('uses a classroom-friendly OTP send bucket for %s', async (path) => {
1742
- vi.stubEnv('NODE_ENV', 'production');
1743
- vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1744
- vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1745
- mocks.redis.mockReturnValue({});
1746
- mocks.extractIp.mockReturnValue('1.2.3.4');
1747
- mocks.isBlocked.mockResolvedValue(null);
1748
- mocks.limit.mockResolvedValueOnce({
1749
- success: false,
1750
- limit: 30,
1751
- remaining: 0,
1752
- reset: Date.now() + 15_000,
1753
- });
1755
+ it.each(['/api/v1/auth/otp/send', '/api/v1/auth/mobile/send-otp'])(
1756
+ 'uses a classroom-friendly OTP send bucket for %s',
1757
+ async (path) => {
1758
+ vi.stubEnv('NODE_ENV', 'production');
1759
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1760
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1761
+ mocks.redis.mockReturnValue({});
1762
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1763
+ mocks.isBlocked.mockResolvedValue(null);
1764
+ mocks.limit.mockResolvedValueOnce({
1765
+ success: false,
1766
+ limit: 30,
1767
+ remaining: 0,
1768
+ reset: Date.now() + 15_000,
1769
+ });
1754
1770
 
1755
- const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1756
- await import('../api-proxy-guard.js');
1757
- clearApiProxyGuardLimiterCache();
1771
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1772
+ await import('../api-proxy-guard.js');
1773
+ clearApiProxyGuardLimiterCache();
1758
1774
 
1759
- const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
1760
- prefixBase: 'proxy:test:api',
1761
- });
1775
+ const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
1776
+ prefixBase: 'proxy:test:api',
1777
+ });
1762
1778
 
1763
- expect(response?.status).toBe(429);
1764
- expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
1765
- expect(response?.headers.get('X-RateLimit-Policy')).toBe('otp-send');
1766
- });
1779
+ expect(response?.status).toBe(429);
1780
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
1781
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('otp-send');
1782
+ }
1783
+ );
1767
1784
 
1768
1785
  it('uses a classroom-friendly bucket for cross-app return handoffs', async () => {
1769
1786
  vi.stubEnv('NODE_ENV', 'production');
@@ -763,7 +763,7 @@ function looksLikeWorkspaceApiKey(token: string): boolean {
763
763
  return /^ttr_[A-Za-z0-9_-]+$/.test(token);
764
764
  }
765
765
 
766
- function hasAuthenticatedBearerToken(headers: Headers): boolean {
766
+ export function hasAuthenticatedBearerToken(headers: Headers): boolean {
767
767
  const authHeader = headers.get('authorization');
768
768
  if (!authHeader) {
769
769
  return false;
@@ -0,0 +1,319 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import {
5
+ createCommonMetadata,
6
+ createPageMetadata,
7
+ NO_INDEX_ROBOTS,
8
+ } from './metadata';
9
+
10
+ const repoRoot = resolve(__dirname, '../../../..');
11
+
12
+ const config = {
13
+ description: {
14
+ en: 'English product description.',
15
+ vi: 'Mo ta san pham bang tieng Viet.',
16
+ },
17
+ indexable: true,
18
+ keywords: ['team collaboration', 'workflow automation'],
19
+ name: 'Tuturuuu Test',
20
+ url: 'https://test.tuturuuu.com',
21
+ };
22
+
23
+ describe('createCommonMetadata', () => {
24
+ it('builds localized, indexable product metadata', () => {
25
+ const metadata = createCommonMetadata({ config, locale: 'vi' });
26
+
27
+ expect(metadata.description).toBe('Mo ta san pham bang tieng Viet.');
28
+ expect(metadata.metadataBase?.toString()).toBe(
29
+ 'https://test.tuturuuu.com/'
30
+ );
31
+ expect(metadata.keywords).toEqual([
32
+ 'Tuturuuu Test',
33
+ 'Tuturuuu',
34
+ 'team collaboration',
35
+ 'workflow automation',
36
+ ]);
37
+ expect(metadata.robots).toMatchObject({
38
+ follow: true,
39
+ index: true,
40
+ });
41
+ expect(metadata.openGraph).toMatchObject({
42
+ alternateLocale: ['en_US'],
43
+ description: 'Mo ta san pham bang tieng Viet.',
44
+ locale: 'vi_VN',
45
+ siteName: 'Tuturuuu Test',
46
+ title: 'Tuturuuu Test',
47
+ });
48
+ expect(metadata.twitter).toMatchObject({
49
+ card: 'summary_large_image',
50
+ creator: '@tuturuuu',
51
+ site: '@tuturuuu',
52
+ });
53
+ });
54
+
55
+ it('uses valid shared brand assets without advertising a missing manifest', () => {
56
+ const metadata = createCommonMetadata({ config, locale: 'en' });
57
+
58
+ expect(metadata.icons).toEqual({
59
+ apple: 'https://tuturuuu.com/apple-touch-icon.png',
60
+ icon: 'https://tuturuuu.com/favicon.ico',
61
+ shortcut: 'https://tuturuuu.com/favicon-16x16.png',
62
+ });
63
+ expect(metadata.manifest).toBeUndefined();
64
+ expect(metadata.openGraph).toMatchObject({
65
+ images: [
66
+ {
67
+ alt: 'Tuturuuu Test preview',
68
+ height: 630,
69
+ url: 'https://tuturuuu.com/media/logos/og-image.jpg',
70
+ width: 1200,
71
+ },
72
+ ],
73
+ locale: 'en_US',
74
+ });
75
+ });
76
+
77
+ it('prevents private application surfaces from being indexed', () => {
78
+ const metadata = createCommonMetadata({
79
+ config: {
80
+ ...config,
81
+ indexable: false,
82
+ },
83
+ locale: 'en',
84
+ });
85
+
86
+ expect(metadata.robots).toEqual({
87
+ follow: false,
88
+ googleBot: {
89
+ follow: false,
90
+ index: false,
91
+ },
92
+ index: false,
93
+ });
94
+ });
95
+
96
+ it('includes a manifest only when the application declares one', () => {
97
+ const metadata = createCommonMetadata({
98
+ config: {
99
+ ...config,
100
+ manifest: '/manifest.webmanifest',
101
+ },
102
+ locale: 'en',
103
+ });
104
+
105
+ expect(metadata.manifest).toBe('/manifest.webmanifest');
106
+ });
107
+ });
108
+
109
+ describe('createPageMetadata', () => {
110
+ it('builds canonical, hreflang, and social metadata for a localized page', () => {
111
+ const metadata = createPageMetadata({
112
+ baseUrl: 'https://test.tuturuuu.com',
113
+ description: 'Plan and automate team workflows.',
114
+ image: '/media/workflows-og.png',
115
+ locale: 'vi',
116
+ pathname: '/products/workflows',
117
+ title: 'Workflow Automation',
118
+ });
119
+
120
+ expect(metadata.alternates).toEqual({
121
+ canonical: 'https://test.tuturuuu.com/vi/products/workflows',
122
+ languages: {
123
+ 'en-US': 'https://test.tuturuuu.com/products/workflows',
124
+ 'vi-VN': 'https://test.tuturuuu.com/vi/products/workflows',
125
+ 'x-default': 'https://test.tuturuuu.com/products/workflows',
126
+ },
127
+ });
128
+ expect(metadata.openGraph).toMatchObject({
129
+ alternateLocale: ['en_US'],
130
+ description: 'Plan and automate team workflows.',
131
+ locale: 'vi_VN',
132
+ siteName: 'Tuturuuu',
133
+ title: 'Workflow Automation',
134
+ url: 'https://test.tuturuuu.com/vi/products/workflows',
135
+ images: [
136
+ {
137
+ alt: 'Workflow Automation',
138
+ height: 630,
139
+ url: 'https://test.tuturuuu.com/media/workflows-og.png',
140
+ width: 1200,
141
+ },
142
+ ],
143
+ });
144
+ expect(metadata.twitter).toMatchObject({
145
+ card: 'summary_large_image',
146
+ creator: '@tuturuuu',
147
+ site: '@tuturuuu',
148
+ title: 'Workflow Automation',
149
+ });
150
+ });
151
+
152
+ it('does not override inherited robots unless page indexing is explicit', () => {
153
+ const baseConfig = {
154
+ baseUrl: 'https://test.tuturuuu.com',
155
+ description: 'Private workspace page.',
156
+ locale: 'en',
157
+ pathname: '/workspace',
158
+ title: 'Workspace',
159
+ };
160
+
161
+ expect(createPageMetadata(baseConfig).robots).toBeUndefined();
162
+ expect(
163
+ createPageMetadata({ ...baseConfig, indexable: false }).robots
164
+ ).toEqual(NO_INDEX_ROBOTS);
165
+ });
166
+
167
+ it('uses the unprefixed English URL configured by the public router', () => {
168
+ const metadata = createPageMetadata({
169
+ baseUrl: 'https://test.tuturuuu.com',
170
+ description: 'The main product landing page.',
171
+ locale: 'en',
172
+ pathname: '/',
173
+ title: 'Tuturuuu',
174
+ });
175
+
176
+ expect(metadata.alternates).toEqual({
177
+ canonical: 'https://test.tuturuuu.com/',
178
+ languages: {
179
+ 'en-US': 'https://test.tuturuuu.com/',
180
+ 'vi-VN': 'https://test.tuturuuu.com/vi',
181
+ 'x-default': 'https://test.tuturuuu.com/',
182
+ },
183
+ });
184
+ });
185
+
186
+ it('supports apps that never expose locale prefixes', () => {
187
+ const metadata = createPageMetadata({
188
+ baseUrl: 'https://tools.tuturuuu.com',
189
+ description: 'Localized tools on one stable URL.',
190
+ locale: 'vi',
191
+ localePrefix: 'never',
192
+ pathname: '/qr',
193
+ title: 'QR Code Generator',
194
+ });
195
+
196
+ expect(metadata.alternates).toEqual({
197
+ canonical: 'https://tools.tuturuuu.com/qr',
198
+ });
199
+ expect(metadata.openGraph).toMatchObject({
200
+ locale: 'vi_VN',
201
+ url: 'https://tools.tuturuuu.com/qr',
202
+ });
203
+ });
204
+
205
+ it('supports apps that always expose locale prefixes', () => {
206
+ const metadata = createPageMetadata({
207
+ baseUrl: 'https://example.tuturuuu.com',
208
+ description: 'A prefixed localized page.',
209
+ locale: 'en',
210
+ localePrefix: 'always',
211
+ pathname: '/about',
212
+ title: 'About',
213
+ });
214
+
215
+ expect(metadata.alternates).toMatchObject({
216
+ canonical: 'https://example.tuturuuu.com/en/about',
217
+ languages: {
218
+ 'en-US': 'https://example.tuturuuu.com/en/about',
219
+ 'vi-VN': 'https://example.tuturuuu.com/vi/about',
220
+ },
221
+ });
222
+ });
223
+ });
224
+
225
+ describe('application metadata coverage', () => {
226
+ it('keeps every Next.js application on the shared metadata contract', () => {
227
+ const appsDirectory = resolve(repoRoot, 'apps');
228
+ const nextApps = readdirSync(appsDirectory, { withFileTypes: true })
229
+ .filter(
230
+ (entry) =>
231
+ entry.isDirectory() &&
232
+ existsSync(resolve(appsDirectory, entry.name, 'next.config.ts'))
233
+ )
234
+ .map((entry) => entry.name)
235
+ .sort();
236
+
237
+ expect(nextApps.length).toBeGreaterThan(0);
238
+
239
+ for (const app of nextApps) {
240
+ const appDirectory = resolve(appsDirectory, app, 'src/app');
241
+ const localizedLayout = resolve(appDirectory, '[locale]/layout.tsx');
242
+ const rootLayout = resolve(appDirectory, 'layout.tsx');
243
+ const layoutPath = existsSync(localizedLayout)
244
+ ? localizedLayout
245
+ : rootLayout;
246
+
247
+ expect(existsSync(layoutPath), `${app} is missing a root layout`).toBe(
248
+ true
249
+ );
250
+ expect(
251
+ readFileSync(layoutPath, 'utf8'),
252
+ `${app} is not using the shared metadata contract`
253
+ ).toMatch(/(?:create|generate)CommonMetadata/);
254
+ }
255
+ });
256
+
257
+ it('covers the non-Next public application shells', () => {
258
+ const tanStackHead = readFileSync(
259
+ resolve(repoRoot, 'apps/tanstack-web/src/lib/platform/app-shell.ts'),
260
+ 'utf8'
261
+ );
262
+ const mobileHead = readFileSync(
263
+ resolve(repoRoot, 'apps/mobile/web/index.html'),
264
+ 'utf8'
265
+ );
266
+ const docsConfig = JSON.parse(
267
+ readFileSync(resolve(repoRoot, 'apps/docs/docs.json'), 'utf8')
268
+ ) as { description?: string };
269
+
270
+ expect(tanStackHead).toContain('max-image-preview:large');
271
+ expect(tanStackHead).toContain("name: 'twitter:site'");
272
+ expect(mobileHead).toContain('name="robots" content="noindex, nofollow"');
273
+ expect(mobileHead).toContain('property="og:image"');
274
+ expect(docsConfig.description).toMatch(/Tuturuuu platform/);
275
+ });
276
+
277
+ it('keeps SEO-critical public routes on page-specific metadata', () => {
278
+ const routeFiles = [
279
+ 'apps/apps/src/app/[locale]/page.tsx',
280
+ 'apps/learn/src/app/[locale]/page.tsx',
281
+ 'apps/teach/src/app/[locale]/page.tsx',
282
+ 'apps/tools/src/app/[locale]/page.tsx',
283
+ 'apps/tools/src/app/[locale]/qr/page.tsx',
284
+ 'apps/tools/src/app/[locale]/random/page.tsx',
285
+ 'apps/storefront/src/app/[locale]/[storeSlug]/page.tsx',
286
+ 'apps/storefront/src/app/[locale]/[storeSlug]/products/[listingId]/page.tsx',
287
+ 'apps/nova/src/app/[locale]/(marketing)/layout.tsx',
288
+ 'apps/nova/src/app/[locale]/(marketing)/learn/layout.tsx',
289
+ ];
290
+
291
+ for (const routeFile of routeFiles) {
292
+ const source = readFileSync(resolve(repoRoot, routeFile), 'utf8');
293
+
294
+ expect(
295
+ source,
296
+ `${routeFile} is missing page-specific SEO metadata`
297
+ ).toMatch(/create(?:Page|NovaPage)Metadata/);
298
+ }
299
+
300
+ const marketingLayouts = readdirSync(
301
+ resolve(repoRoot, 'apps/web/src/app/[locale]/(marketing)'),
302
+ { recursive: true, withFileTypes: true }
303
+ )
304
+ .filter(
305
+ (entry) =>
306
+ entry.isFile() &&
307
+ entry.name === 'layout.tsx' &&
308
+ readFileSync(resolve(entry.parentPath, entry.name), 'utf8').includes(
309
+ 'createMarketingMetadata'
310
+ )
311
+ )
312
+ .map((entry) => resolve(entry.parentPath, entry.name));
313
+
314
+ expect(marketingLayouts).toHaveLength(34);
315
+ for (const layoutPath of marketingLayouts) {
316
+ expect(readFileSync(layoutPath, 'utf8')).toMatch(/pathname:\s*['`]/);
317
+ }
318
+ });
319
+ });
@@ -0,0 +1,265 @@
1
+ import type { Metadata } from 'next';
2
+ import { DEV_MODE } from '../constants';
3
+
4
+ const TUTURUUU_URL = 'https://tuturuuu.com';
5
+ const DEFAULT_OG_IMAGE = `${TUTURUUU_URL}/media/logos/og-image.jpg`;
6
+
7
+ export const NO_INDEX_ROBOTS = {
8
+ index: false,
9
+ follow: false,
10
+ googleBot: {
11
+ index: false,
12
+ follow: false,
13
+ },
14
+ } satisfies NonNullable<Metadata['robots']>;
15
+
16
+ export interface CommonMetadataConfig {
17
+ name: string;
18
+ url: string;
19
+ indexable: boolean;
20
+ keywords: string[];
21
+ description: {
22
+ en: string;
23
+ vi?: string;
24
+ };
25
+ authors?: Metadata['authors'];
26
+ category?: string;
27
+ creator?: string;
28
+ icons?: Metadata['icons'];
29
+ manifest?: string;
30
+ ogImage?: string;
31
+ publisher?: string;
32
+ }
33
+
34
+ export interface PageMetadataConfig {
35
+ baseUrl: string;
36
+ description: string;
37
+ image?: string;
38
+ imageAlt?: string;
39
+ indexable?: boolean;
40
+ keywords?: string[];
41
+ locale: string;
42
+ localePrefix?: 'always' | 'as-needed' | 'never';
43
+ pathname: string;
44
+ siteName?: string;
45
+ socialDescription?: string;
46
+ socialTitle?: string;
47
+ title: string;
48
+ }
49
+
50
+ interface CreateCommonMetadataProps {
51
+ config: CommonMetadataConfig;
52
+ locale: string;
53
+ }
54
+
55
+ interface GenerateCommonMetadataProps {
56
+ config: CommonMetadataConfig;
57
+ params: Promise<{
58
+ locale: string;
59
+ }>;
60
+ }
61
+
62
+ interface GeneratePageMetadataProps {
63
+ config: Omit<PageMetadataConfig, 'locale'>;
64
+ params: Promise<{
65
+ locale: string;
66
+ }>;
67
+ }
68
+
69
+ function getOpenGraphLocale(locale: string) {
70
+ return locale === 'vi' ? 'vi_VN' : 'en_US';
71
+ }
72
+
73
+ function getLocalizedUrl(
74
+ baseUrl: string,
75
+ locale: string,
76
+ pathname: string,
77
+ localePrefix: NonNullable<PageMetadataConfig['localePrefix']>
78
+ ) {
79
+ const normalizedPathname =
80
+ pathname === '/' ? '' : `/${pathname.replace(/^\/+|\/+$/g, '')}`;
81
+ const localizedPrefix =
82
+ localePrefix === 'never' ||
83
+ (localePrefix === 'as-needed' && locale === 'en')
84
+ ? ''
85
+ : `/${locale}`;
86
+ const localizedPathname = `${localizedPrefix}${normalizedPathname}` || '/';
87
+
88
+ return new URL(localizedPathname, baseUrl).toString();
89
+ }
90
+
91
+ function getRobotsMetadata(indexable: boolean): Metadata['robots'] {
92
+ if (!indexable) {
93
+ return NO_INDEX_ROBOTS;
94
+ }
95
+
96
+ return {
97
+ index: true,
98
+ follow: true,
99
+ googleBot: {
100
+ index: true,
101
+ follow: true,
102
+ 'max-image-preview': 'large',
103
+ 'max-snippet': -1,
104
+ 'max-video-preview': -1,
105
+ },
106
+ };
107
+ }
108
+
109
+ export function createCommonMetadata({
110
+ config,
111
+ locale,
112
+ }: CreateCommonMetadataProps): Metadata {
113
+ const description =
114
+ locale === 'vi' && config.description.vi
115
+ ? config.description.vi
116
+ : config.description.en;
117
+ const ogImage = config.ogImage ?? DEFAULT_OG_IMAGE;
118
+ const openGraphLocale = getOpenGraphLocale(locale);
119
+
120
+ return {
121
+ applicationName: config.name,
122
+ title: {
123
+ default: config.name,
124
+ template: `${DEV_MODE ? '[DEV] ' : ''}%s | ${config.name}`,
125
+ },
126
+ metadataBase: new URL(config.url),
127
+ description,
128
+ keywords: [...new Set([config.name, 'Tuturuuu', ...config.keywords])],
129
+ authors: config.authors ?? [
130
+ {
131
+ name: 'Tuturuuu',
132
+ url: TUTURUUU_URL,
133
+ },
134
+ ],
135
+ creator: config.creator ?? 'Tuturuuu',
136
+ publisher: config.publisher ?? 'Tuturuuu',
137
+ category: config.category,
138
+ referrer: 'origin-when-cross-origin',
139
+ robots: getRobotsMetadata(config.indexable),
140
+ appleWebApp: {
141
+ capable: true,
142
+ statusBarStyle: 'default',
143
+ title: config.name,
144
+ },
145
+ formatDetection: {
146
+ address: false,
147
+ email: false,
148
+ telephone: false,
149
+ },
150
+ openGraph: {
151
+ type: 'website',
152
+ locale: openGraphLocale,
153
+ alternateLocale: [openGraphLocale === 'vi_VN' ? 'en_US' : 'vi_VN'],
154
+ title: config.name,
155
+ description,
156
+ siteName: config.name,
157
+ images: [
158
+ {
159
+ url: ogImage,
160
+ width: 1200,
161
+ height: 630,
162
+ alt: `${config.name} preview`,
163
+ },
164
+ ],
165
+ },
166
+ twitter: {
167
+ card: 'summary_large_image',
168
+ title: config.name,
169
+ description,
170
+ images: [ogImage],
171
+ creator: '@tuturuuu',
172
+ site: '@tuturuuu',
173
+ },
174
+ icons: config.icons ?? {
175
+ icon: `${TUTURUUU_URL}/favicon.ico`,
176
+ shortcut: `${TUTURUUU_URL}/favicon-16x16.png`,
177
+ apple: `${TUTURUUU_URL}/apple-touch-icon.png`,
178
+ },
179
+ manifest: config.manifest,
180
+ };
181
+ }
182
+
183
+ export function createPageMetadata({
184
+ baseUrl,
185
+ description,
186
+ image = DEFAULT_OG_IMAGE,
187
+ imageAlt,
188
+ indexable,
189
+ keywords,
190
+ locale,
191
+ localePrefix = 'as-needed',
192
+ pathname,
193
+ siteName = 'Tuturuuu',
194
+ title,
195
+ socialDescription = description,
196
+ socialTitle = title,
197
+ }: PageMetadataConfig): Metadata {
198
+ const canonical = getLocalizedUrl(baseUrl, locale, pathname, localePrefix);
199
+ const englishUrl = getLocalizedUrl(baseUrl, 'en', pathname, localePrefix);
200
+ const vietnameseUrl = getLocalizedUrl(baseUrl, 'vi', pathname, localePrefix);
201
+ const openGraphLocale = getOpenGraphLocale(locale);
202
+ const absoluteImage = new URL(image, baseUrl).toString();
203
+
204
+ return {
205
+ title,
206
+ description,
207
+ ...(keywords?.length ? { keywords } : {}),
208
+ ...(typeof indexable === 'boolean'
209
+ ? { robots: getRobotsMetadata(indexable) }
210
+ : {}),
211
+ alternates: {
212
+ canonical,
213
+ ...(localePrefix === 'never'
214
+ ? {}
215
+ : {
216
+ languages: {
217
+ 'en-US': englishUrl,
218
+ 'vi-VN': vietnameseUrl,
219
+ 'x-default': englishUrl,
220
+ },
221
+ }),
222
+ },
223
+ openGraph: {
224
+ type: 'website',
225
+ url: canonical,
226
+ locale: openGraphLocale,
227
+ alternateLocale: [openGraphLocale === 'vi_VN' ? 'en_US' : 'vi_VN'],
228
+ siteName,
229
+ title: socialTitle,
230
+ description: socialDescription,
231
+ images: [
232
+ {
233
+ url: absoluteImage,
234
+ width: 1200,
235
+ height: 630,
236
+ alt: imageAlt ?? socialTitle,
237
+ },
238
+ ],
239
+ },
240
+ twitter: {
241
+ card: 'summary_large_image',
242
+ title: socialTitle,
243
+ description: socialDescription,
244
+ images: [absoluteImage],
245
+ creator: '@tuturuuu',
246
+ site: '@tuturuuu',
247
+ },
248
+ };
249
+ }
250
+
251
+ export async function generateCommonMetadata({
252
+ config,
253
+ params,
254
+ }: GenerateCommonMetadataProps): Promise<Metadata> {
255
+ const { locale } = await params;
256
+ return createCommonMetadata({ config, locale });
257
+ }
258
+
259
+ export async function generatePageMetadata({
260
+ config,
261
+ params,
262
+ }: GeneratePageMetadataProps): Promise<Metadata> {
263
+ const { locale } = await params;
264
+ return createPageMetadata({ ...config, locale });
265
+ }
@@ -1,91 +1,21 @@
1
- import type { Metadata, Viewport } from 'next';
1
+ import type { Viewport } from 'next';
2
2
  import { Noto_Sans } from 'next/font/google';
3
- import { DEV_MODE } from '../constants';
3
+
4
+ export {
5
+ type CommonMetadataConfig,
6
+ createCommonMetadata,
7
+ createPageMetadata,
8
+ generateCommonMetadata,
9
+ generatePageMetadata,
10
+ NO_INDEX_ROBOTS,
11
+ type PageMetadataConfig,
12
+ } from './metadata';
4
13
 
5
14
  export const font = Noto_Sans({
6
15
  subsets: ['latin', 'vietnamese'],
7
16
  display: 'block',
8
17
  });
9
18
 
10
- interface MetadataProps {
11
- config: {
12
- name: string;
13
- url: string;
14
- ogImage: string;
15
- keywords?: string[];
16
- description: {
17
- en: string;
18
- vi?: string;
19
- };
20
- };
21
- params: Promise<{
22
- locale: string;
23
- }>;
24
- }
25
-
26
- export async function generateCommonMetadata({
27
- config,
28
- params,
29
- }: MetadataProps): Promise<Metadata> {
30
- const { locale } = await params;
31
- const description =
32
- locale === 'vi' ? config.description.vi : config.description.en;
33
-
34
- return {
35
- applicationName: config.name,
36
- title: {
37
- default: config.name,
38
- template: `${DEV_MODE ? '[DEV] ' : ''} %s | ${config.name}`,
39
- },
40
- metadataBase: new URL(config.url),
41
- description,
42
- keywords: config.keywords ?? [
43
- 'React.js',
44
- 'Next.js',
45
- 'Tailwind CSS',
46
- 'TypeScript',
47
- 'Biome',
48
- ],
49
- appleWebApp: {
50
- capable: true,
51
- statusBarStyle: 'default',
52
- title: config.name,
53
- },
54
- formatDetection: {
55
- telephone: false,
56
- },
57
- openGraph: {
58
- type: 'website',
59
- locale,
60
- url: config.url,
61
- title: config.name,
62
- description,
63
- siteName: config.name,
64
- images: [
65
- {
66
- url: config.ogImage,
67
- width: 1200,
68
- height: 630,
69
- alt: config.name,
70
- },
71
- ],
72
- },
73
- twitter: {
74
- card: 'summary_large_image',
75
- title: config.name,
76
- description,
77
- images: [config.ogImage],
78
- creator: '@tuturuuu',
79
- },
80
- icons: {
81
- icon: '/favicon.ico',
82
- shortcut: '/favicon-16x16.png',
83
- apple: '/apple-touch-icon.png',
84
- },
85
- manifest: '/manifest.webmanifest',
86
- };
87
- }
88
-
89
19
  export const viewport: Viewport = {
90
20
  width: 'device-width',
91
21
  initialScale: 1,
@@ -1,3 +1,4 @@
1
+ import { tryToParsePath } from 'next/dist/lib/try-to-parse-path';
1
2
  import { describe, expect, it } from 'vitest';
2
3
  import {
3
4
  createTuturuuuNextConfig,
@@ -16,6 +17,7 @@ describe('createTuturuuuNextConfig', () => {
16
17
  expect(config.allowedDevOrigins).toContain('tuturuuu.localhost');
17
18
  expect(config.cacheComponents).toBe(true);
18
19
  expect(config.partialPrefetching).toBe(true);
20
+ expect(config.experimental?.useTypeScriptCli).toBe(true);
19
21
  expect(config.experimental?.turbopackFileSystemCacheForBuild).toBe(true);
20
22
  expect(config.experimental?.turbopackRustReactCompiler).toBe(true);
21
23
  expect(config.images?.remotePatterns).toEqual(
@@ -40,6 +42,97 @@ describe('createTuturuuuNextConfig', () => {
40
42
  });
41
43
  });
42
44
 
45
+ it('protects responses from framing by default', async () => {
46
+ const config = createTuturuuuNextConfig();
47
+
48
+ await expect(config.headers?.()).resolves.toEqual([
49
+ {
50
+ source:
51
+ '/:path((?!api/v1/workspaces/[^/]+/external-projects/assets/[^/]+/webgl(?:/|$)).*)',
52
+ headers: [
53
+ {
54
+ key: 'Content-Security-Policy',
55
+ value: "frame-ancestors 'none'",
56
+ },
57
+ {
58
+ key: 'X-Frame-Options',
59
+ value: 'DENY',
60
+ },
61
+ ],
62
+ },
63
+ ]);
64
+ });
65
+
66
+ it('preserves app-specific response headers after shared security headers', async () => {
67
+ const config = createTuturuuuNextConfig({
68
+ async headers() {
69
+ return [
70
+ {
71
+ source: '/login',
72
+ headers: [
73
+ {
74
+ key: 'Cache-Control',
75
+ value: 'public, max-age=0, must-revalidate',
76
+ },
77
+ {
78
+ key: 'X-Robots-Tag',
79
+ value: 'noindex, nofollow',
80
+ },
81
+ ],
82
+ },
83
+ ];
84
+ },
85
+ });
86
+
87
+ const headers = await config.headers?.();
88
+
89
+ expect(headers).toHaveLength(2);
90
+ expect(headers?.[1]).toEqual({
91
+ source: '/login',
92
+ headers: [
93
+ {
94
+ key: 'Cache-Control',
95
+ value: 'public, max-age=0, must-revalidate',
96
+ },
97
+ {
98
+ key: 'X-Robots-Tag',
99
+ value: 'noindex, nofollow',
100
+ },
101
+ ],
102
+ });
103
+ });
104
+
105
+ it('exempts only the sandboxed CMS WebGL asset route from anti-framing headers', async () => {
106
+ const config = createTuturuuuNextConfig();
107
+ const [antiFramingRule] = (await config.headers?.()) ?? [];
108
+ const parsedPath = tryToParsePath(antiFramingRule?.source ?? '');
109
+
110
+ expect(parsedPath.error).toBeUndefined();
111
+ expect(parsedPath.regexStr).toBeDefined();
112
+
113
+ const routePattern = new RegExp(parsedPath.regexStr ?? '');
114
+
115
+ expect(routePattern.test('/')).toBe(true);
116
+ expect(routePattern.test('/login')).toBe(true);
117
+ expect(routePattern.test('/en/workspace')).toBe(true);
118
+ expect(routePattern.test('/api/billing/workspace/invoice')).toBe(true);
119
+ expect(
120
+ routePattern.test(
121
+ '/api/v1/workspaces/workspace-id/external-projects/assets/asset-id/webgl/index.html'
122
+ )
123
+ ).toBe(false);
124
+ expect(
125
+ routePattern.test(
126
+ '/api/v1/workspaces/workspace-id/external-projects/assets/asset-id/webgl'
127
+ )
128
+ ).toBe(false);
129
+ expect(
130
+ routePattern.test(
131
+ '/api/v1/workspaces/workspace-id/external-projects/assets/asset-id/webgl-preview'
132
+ )
133
+ ).toBe(true);
134
+ });
135
+
43
136
  it('allows the exact worktree-prefixed Portless host for Next dev assets', () => {
44
137
  const originalPortlessUrl = process.env.PORTLESS_URL;
45
138
 
@@ -106,6 +199,7 @@ describe('createTuturuuuNextConfig', () => {
106
199
  cpus: 2,
107
200
  turbopackFileSystemCacheForBuild: false,
108
201
  turbopackRustReactCompiler: false,
202
+ useTypeScriptCli: false,
109
203
  },
110
204
  typescript: {
111
205
  ignoreBuildErrors: false,
@@ -119,6 +213,7 @@ describe('createTuturuuuNextConfig', () => {
119
213
  expect(config.experimental?.cpus).toBe(2);
120
214
  expect(config.experimental?.turbopackFileSystemCacheForBuild).toBe(false);
121
215
  expect(config.experimental?.turbopackRustReactCompiler).toBe(false);
216
+ expect(config.experimental?.useTypeScriptCli).toBe(true);
122
217
  expect(config.typescript?.ignoreBuildErrors).toBe(false);
123
218
  });
124
219
 
@@ -33,6 +33,20 @@ export const TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS = [
33
33
  },
34
34
  ] satisfies NextImageRemotePattern[];
35
35
 
36
+ const TUTURUUU_ANTI_FRAMING_SOURCE =
37
+ '/:path((?!api/v1/workspaces/[^/]+/external-projects/assets/[^/]+/webgl(?:/|$)).*)';
38
+
39
+ const TUTURUUU_ANTI_FRAMING_HEADERS = [
40
+ {
41
+ key: 'Content-Security-Policy',
42
+ value: "frame-ancestors 'none'",
43
+ },
44
+ {
45
+ key: 'X-Frame-Options',
46
+ value: 'DENY',
47
+ },
48
+ ];
49
+
36
50
  function mergeStringArrays(
37
51
  first: readonly string[] | undefined,
38
52
  second: readonly string[] | undefined
@@ -98,6 +112,7 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
98
112
  },
99
113
  experimental: {
100
114
  ...experimentalConfig,
115
+ useTypeScriptCli: true,
101
116
  turbopackFileSystemCacheForBuild:
102
117
  experimentalConfig.turbopackFileSystemCacheForBuild ?? true,
103
118
  turbopackRustReactCompiler:
@@ -107,6 +122,15 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
107
122
  experimentalConfig.optimizePackageImports
108
123
  ),
109
124
  },
125
+ async headers() {
126
+ return [
127
+ {
128
+ source: TUTURUUU_ANTI_FRAMING_SOURCE,
129
+ headers: TUTURUUU_ANTI_FRAMING_HEADERS,
130
+ },
131
+ ...((await config.headers?.()) ?? []),
132
+ ];
133
+ },
110
134
  };
111
135
  }
112
136
 
@@ -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.21.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.23.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.21.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.23.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. */