Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/validation-and-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jobs:
command: vp run test:redirects
- name: Type Check
command: vp run test:types
- name: Unit Tests
command: vp run test:unit

steps:
# Check out the repository
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dist-ssr

# CloudFlare
.cloudflare
.wrangler
_redirects
_redirects.json

Expand Down
19 changes: 19 additions & 0 deletions app/utils/build-phone-uri.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, test } from 'vite-plus/test'

import { buildPhoneUri } from './build-phone-uri'

test('builds a tel URI from a spaced international number', () => {
expect(buildPhoneUri('+49 351 0000000')).toBe('tel:+493510000000')
})

test('keeps an existing tel URI valid', () => {
expect(buildPhoneUri('tel:+493510000000')).toBe('tel:+493510000000')
})

test('strips common display separators', () => {
expect(buildPhoneUri('+49 (351) 000-0000')).toBe('tel:+493510000000')
})

test('returns an empty string when input contains no digits', () => {
expect(buildPhoneUri('contact me maybe')).toBe('')
})
112 changes: 112 additions & 0 deletions app/utils/buildVCard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vite-plus/test'

import { buildVCard } from './buildVCard'

const projectMetadata = {
author: {
lastName: 'Seyschab',
firstName: 'Thorsten',
handle: '@toddeTV',
name: 'Thorsten Seyschab',
nickname: 'toddeTV',
role: 'IT consultant, senior full-stack developer, and conference speaker',
},
siteUrl: 'https://todde.tv',
} as const

describe('buildVCard', () => {
it('renders selected fixed fields and contact entries in vCard order', () => {
const card = buildVCard(
projectMetadata,
{
name: true,
nickname: true,
website: true,
bio: true,
},
[
'hello@todde.tv',
],
[
'+4917691404834',
],
[
{
name: 'GitHub',
url: 'https://github.com/toddeTV',
},
],
)

expect(card).toBe([
'BEGIN:VCARD',
'VERSION:3.0',
'N:Seyschab;Thorsten;;;',
'FN:Thorsten Seyschab',
'NICKNAME:toddeTV',
'URL:https://todde.tv',
'NOTE:@toddeTV - IT consultant\\, senior full-stack developer\\, and conference speaker',
'EMAIL:hello@todde.tv',
'TEL;TYPE=CELL:+4917691404834',
'X-SOCIALPROFILE;TYPE=GitHub:https://github.com/toddeTV',
'END:VCARD',
].join('\r\n'))
})

it('escapes vCard value characters in names, notes, and social type labels', () => {
const card = buildVCard(
{
author: {
lastName: 'Semi;Colon',
firstName: 'Comma,Slash\\Line\nBreak',
handle: '@semi;comma',
name: 'Semi;Colon, Slash\\Line\nBreak',
nickname: 'nick;name',
role: 'Line one\nLine two',
},
siteUrl: 'https://todde.tv',
},
{
name: true,
nickname: true,
website: false,
bio: true,
},
[],
[],
[
{
name: 'Mastodon;Profile',
url: 'https://example.com/@todde',
},
],
)

expect(card).toContain('N:Semi\\;Colon;Comma\\,Slash\\\\Line\\nBreak;;;')
expect(card).toContain('FN:Semi\\;Colon\\, Slash\\\\Line\\nBreak')
expect(card).toContain('NICKNAME:nick\\;name')
expect(card).toContain('NOTE:@semi\\;comma - Line one\\nLine two')
expect(card).toContain('X-SOCIALPROFILE;TYPE=Mastodon\\;Profile:https://example.com/@todde')
})

it('omits optional fields that are not selected', () => {
const card = buildVCard(
projectMetadata,
{
name: false,
nickname: false,
website: false,
bio: false,
},
[],
[],
[],
)

expect(card).toBe([
'BEGIN:VCARD',
'VERSION:3.0',
'END:VCARD',
].join('\r\n'))
})
})
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default withNuxt(
'.output/',
'dist/',
'.data/',
'.wrangler/',
'.claude/skills/**/.skilld/',
'pnpm-lock.yaml',
],
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,18 @@
"fix:lint": "run-s \"test:lint --fix\"",
"nuxi": "nuxi",
"nuxt": "nuxt",
"prepare": "nuxt prepare",
"preview:ssg": "pnpm exec serve .output/public",
"postinstall": "nuxt prepare",
"prepare": "vp config --hooks-only",
"preview:ssg": "vp exec serve .output/public",
"preview:ssr": "nuxt preview",
"redirects:check": "vp dlx tsx@4.21.0 scripts/generate-redirects.ts --check",
"redirects:generate": "vp dlx tsx@4.21.0 scripts/generate-redirects.ts",
"reset": "rimraf node_modules .nuxt .output dist .data",
"test": "run-s test:*",
"test": "run-s test:lint test:redirects test:types test:unit",
"test:lint": "eslint .",
"test:redirects": "vp run redirects:generate && vp run redirects:check",
"test:types": "nuxt typecheck",
"test:unit": "vp test app/ server/ shared/ scripts/",
"verify:cloudflare:smoke": "vp dlx tsx@4.21.0 scripts/check-cloudflare-smoke.ts"
},
"dependencies": {
Expand Down
41 changes: 2 additions & 39 deletions server/routes/.well-known/security.txt.get.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,6 @@
const defaultSecurityTxtLifetimeDays = 180
const projectMetadata = getProjectMetadata()

interface SecurityTxtContentOptions {
canonicalUrl: string
contact: string
policyUrl?: string
preferredLanguages: string[]
}

/** Returns the ISO 8601 expiration timestamp for `security.txt`. */
function createSecurityTxtExpires(
now: Date = new Date(),
lifetimeDays: number = defaultSecurityTxtLifetimeDays,
): string {
const expiresAt = new Date(now.getTime())

expiresAt.setUTCDate(expiresAt.getUTCDate() + lifetimeDays)
import { buildSecurityTxtContent } from '#server/utils/security-txt'

return expiresAt.toISOString()
}

/** Builds the plain text `security.txt` response body from route metadata. */
function buildSecurityTxtContent(
options: SecurityTxtContentOptions,
now: Date = new Date(),
): string {
const lines = [
`Contact: ${options.contact}`,
`Expires: ${createSecurityTxtExpires(now)}`,
`Canonical: ${options.canonicalUrl}`,
`Preferred-Languages: ${options.preferredLanguages.join(', ')}`,
]

if (options.policyUrl) {
lines.push(`Policy: ${options.policyUrl}`)
}

return `${lines.join('\n')}\n`
}
const projectMetadata = getProjectMetadata()

export default defineEventHandler((event) => {
const content = buildSecurityTxtContent({
Expand Down
63 changes: 63 additions & 0 deletions server/utils/build-release-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'

import { resolveBuildReleaseMetadata } from './build-release-metadata'

afterEach(() => {
vi.useRealTimers()
})

describe('resolveBuildReleaseMetadata', () => {
it('prefers explicit build metadata env values in declared order', () => {
const metadata = resolveBuildReleaseMetadata({
BUILD_RELEASE_DATE: '2026-04-15',
BUILD_RELEASE_COMMIT_SHORT: 'ABCDEF123456',
GITHUB_SHA: '1234567890abcdef',
}, () => '7654321')

expect(metadata).toEqual({
buildDateIso: '2026-04-15',
commitShort: 'abcdef1',
releaseLabel: '2026.04.15+abcdef1',
})
})

it('normalizes datetime env values and falls back to git resolver for commit', () => {
const metadata = resolveBuildReleaseMetadata({
NUXT_BUILD_RELEASE_DATE: '2026-04-15T19:20:21.000Z',
}, () => 'Feature abcdef1234567890')

expect(metadata).toEqual({
buildDateIso: '2026-04-15',
commitShort: 'abcdef1',
releaseLabel: '2026.04.15+abcdef1',
})
})

it('falls back to current UTC date when build date is invalid', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-23T08:00:00.000Z'))

const metadata = resolveBuildReleaseMetadata({
BUILD_RELEASE_DATE: '2026-02-30',
}, () => 'abcdef1')

expect(metadata).toEqual({
buildDateIso: '2026-05-23',
commitShort: 'abcdef1',
releaseLabel: '2026.05.23+abcdef1',
})
})

it('uses unknown when no valid commit can be resolved', () => {
const metadata = resolveBuildReleaseMetadata({
BUILD_RELEASE_DATE: '2026-04-15',
GITHUB_SHA: 'not-a-sha',
}, () => undefined)

expect(metadata).toEqual({
buildDateIso: '2026-04-15',
commitShort: 'unknown',
releaseLabel: '2026.04.15+unknown',
})
})
})
47 changes: 47 additions & 0 deletions server/utils/security-txt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vite-plus/test'
import {
buildSecurityTxtContent,
createSecurityTxtExpires,
defaultSecurityTxtLifetimeDays,
} from './security-txt'

describe('security txt utilities', () => {
it('creates an RFC 3339 expires value within the default lifetime', () => {
const now = new Date('2026-05-10T12:34:56.000Z')

expect(defaultSecurityTxtLifetimeDays).toBe(180)
expect(createSecurityTxtExpires(now)).toBe('2026-11-06T12:34:56.000Z')
})

it('renders required security.txt fields', () => {
const content = buildSecurityTxtContent({
canonicalUrl: 'https://todde.tv/.well-known/security.txt',
contact: 'mailto:hello@todde.tv',
preferredLanguages: [
'en',
'de',
],
}, new Date('2026-05-10T12:34:56.000Z'))

expect(content).toBe([
'Contact: mailto:hello@todde.tv',
'Expires: 2026-11-06T12:34:56.000Z',
'Canonical: https://todde.tv/.well-known/security.txt',
'Preferred-Languages: en, de',
'',
].join('\n'))
})

it('renders the optional policy field when configured', () => {
const content = buildSecurityTxtContent({
canonicalUrl: 'https://todde.tv/.well-known/security.txt',
contact: 'mailto:hello@todde.tv',
policyUrl: 'https://todde.tv/legal-notice',
preferredLanguages: [
'en',
],
}, new Date('2026-05-10T12:34:56.000Z'))

expect(content).toContain('Policy: https://todde.tv/legal-notice')
})
})
39 changes: 39 additions & 0 deletions server/utils/security-txt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export const defaultSecurityTxtLifetimeDays = 180

export interface SecurityTxtContentOptions {
canonicalUrl: string
contact: string
policyUrl?: string
preferredLanguages: string[]
}

/** Returns the ISO 8601 expiration timestamp for `security.txt`. */
export function createSecurityTxtExpires(
now: Date = new Date(),
lifetimeDays: number = defaultSecurityTxtLifetimeDays,
): string {
const expiresAt = new Date(now.getTime())

expiresAt.setUTCDate(expiresAt.getUTCDate() + lifetimeDays)

return expiresAt.toISOString()
}

/** Builds the plain text `security.txt` response body from route metadata. */
export function buildSecurityTxtContent(
options: SecurityTxtContentOptions,
now: Date = new Date(),
): string {
const lines = [
`Contact: ${options.contact}`,
`Expires: ${createSecurityTxtExpires(now)}`,
`Canonical: ${options.canonicalUrl}`,
`Preferred-Languages: ${options.preferredLanguages.join(', ')}`,
]

if (options.policyUrl) {
lines.push(`Policy: ${options.policyUrl}`)
}

return `${lines.join('\n')}\n`
}
Loading