Skip to content
Merged
3 changes: 2 additions & 1 deletion CRON.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Generated from literal `defineScheduledTask` declarations. Run `pnpm cron:docs` after schedule changes.

Tasks: 17. Unique Cloudflare triggers: 14.
Tasks: 18. Unique Cloudflare triggers: 14.

| Task | Cron | Source |
| --- | --- | --- |
Expand All @@ -13,6 +13,7 @@ Tasks: 17. Unique Cloudflare triggers: 14.
| `detect-star-surges` | `30 4 * * *` | `layers/registry/server/tasks/detect-star-surges.ts` |
| `drain-skill-dirty` | `*/5 * * * *` | `layers/registry/server/tasks/drain-skill-dirty.ts` |
| `embedding-parity-audit` | `0 21 * * *` | `layers/registry/server/tasks/embedding-parity-audit.ts` |
| `purge-personal-data` | `30 4 * * *` | `layers/identity/server/tasks/purge-personal-data.ts` |
| `recompute-skill-scores` | `0 3 * * *` | `layers/registry/server/tasks/recompute-skill-scores.ts` |
| `reconcile-rendered` | `20 */6 * * *` | `layers/registry/server/tasks/reconcile-rendered.ts` |
| `refresh-x-engagement` | `10 * * * *` | `layers/registry/server/tasks/refresh-x-engagement.ts` |
Expand Down
6 changes: 5 additions & 1 deletion layers/identity/server/api/auth/logout.post.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { defineApiHandler } from '#shared/server/handler'
import { clearGithubUserCredentials } from '../../utils/users'

export default defineApiHandler({
handler: async ({ event }) => {
handler: async ({ event, platform, session }) => {
const userId = (session?.user as { id?: number } | undefined)?.id
if (typeof userId === 'number')
await clearGithubUserCredentials(platform.db, userId)
await clearUserSession(event)
return { ok: true as const }
},
Expand Down
11 changes: 2 additions & 9 deletions layers/identity/server/api/me/repos/scan.post.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { defineApiHandler } from '#shared/server/handler'
import { authenticated } from '../../../policies/authenticated'
import { ReposScanBody } from '../../../schemas/repos'
import { decryptToken } from '../../../utils/crypto'
import { ownedRepoScanResponse, scanOwnedRepos } from '../../../utils/scan-owned-repos'
import { requireUserRow } from '../../../utils/users'
import { requireGithubUserToken, requireUserRow } from '../../../utils/users'

export default defineApiHandler({
schema: ReposScanBody,
Expand All @@ -13,13 +12,7 @@ export default defineApiHandler({
const config = useRuntimeConfig(event)
const { db, env } = platform

const enc = await db.prepare(
`SELECT github_token_encrypted FROM users WHERE id = ?1`,
).bind(u.id).first<{ github_token_encrypted: string | null }>()
if (!enc?.github_token_encrypted)
throw createError({ statusCode: 401, message: 'Re-authentication required' })

const userToken = await decryptToken(enc.github_token_encrypted, config.tokenKey as string)
const userToken = await requireGithubUserToken(db, u.id, config.tokenKey as string)

const result = await scanOwnedRepos({
login: u.login,
Expand Down
13 changes: 3 additions & 10 deletions layers/identity/server/api/me/stars/sync.post.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { defineApiHandler } from '#shared/server/handler'
import { authenticated } from '../../../policies/authenticated'
import { StarsSyncQuery } from '../../../schemas/stars'
import { decryptToken } from '../../../utils/crypto'
import { requireUserRow } from '../../../utils/users'
import { requireGithubUserToken, requireUserRow } from '../../../utils/users'

interface GitHubStarred {
starred_at: string
Expand All @@ -20,13 +19,7 @@ export default defineApiHandler({
const { db } = platform
const { page } = body

const enc = await db.prepare(
`SELECT github_token_encrypted FROM users WHERE id = ?1`,
).bind(u.id).first<{ github_token_encrypted: string | null }>()
if (!enc?.github_token_encrypted)
throw createError({ statusCode: 401, message: 'Re-authentication required' })

const token = await decryptToken(enc.github_token_encrypted, config.tokenKey as string)
const token = await requireGithubUserToken(db, u.id, config.tokenKey as string)

if (page === 1)
await db.prepare(`DELETE FROM user_starred_repos WHERE user_id = ?1`).bind(u.id).run()
Expand All @@ -40,7 +33,7 @@ export default defineApiHandler({
})
if (!res.ok) {
if (res.status === 401)
throw createError({ statusCode: 401, message: 'Re-authentication required' })
throw createError({ statusCode: 401, statusMessage: 'GitHub sign-in required', message: 'GitHub rejected your access. Sign in with GitHub again.' })
throw createError({ statusCode: 502, message: `GitHub error ${res.status}` })
}
const raw = await res.json() as GitHubStarred[]
Expand Down
54 changes: 54 additions & 0 deletions layers/identity/server/tasks/purge-personal-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/// <reference types="@cloudflare/workers-types" />

import { resolveCloudflareBindings } from '@harlan-zw/nuxt-cloudflare/bindings'
import { runObservedScheduledTask } from '~~/server/utils/scheduled-run'
import { reportJobRun } from '~~/server/utils/sync-job-reporter'
import { observedSchedulePolicy } from '#shared/schedule-policy'
import { purgeRetainedPersonalData } from '../utils/data-retention'

const CRON = '30 4 * * *'

/**
* Daily deletion of personal records past their retention window.
*
* Ended CLI tokens, CLI sign-in codes, device sessions, and old email delivery
* history. `purgeRetainedPersonalData` holds the windows. The task shares the
* 04:30 UTC trigger with detect-star-surges, so it adds no Cloudflare trigger.
*/
export default defineScheduledTask({
name: 'purge-personal-data',
cron: '30 4 * * *',
description: 'Delete ended CLI credentials and email history past their retention window',
async run({ context }) {
const env = resolveCloudflareBindings<Cloudflare.Env>(context)
const db = env?.DB as D1Database | undefined
if (!env || !db) {
emitOperationalEvent(createWideEvent({ operation: 'purge-personal-data', outcome: 'binding-missing' }))
return { result: { error: 'no-db' } }
}

return await runObservedScheduledTask({
db,
env,
context,
policy: observedSchedulePolicy('purge-personal-data'),
}, async () => {
const startedAt = Date.now()
const purged = await purgeRetainedPersonalData(db, Math.floor(startedAt / 1000))
const elapsedMs = Date.now() - startedAt
const deleted = Object.values(purged).reduce((sum, count) => sum + count, 0)

emitOperationalEvent(createWideEvent({
'operation': 'purge-personal-data',
'outcome': 'completed',
'success.count': deleted,
}))
await reportJobRun(db, 'purge-personal-data', {
cron: CRON,
status: 'ok',
durationMs: elapsedMs,
})
return { result: { ...purged, elapsedMs } }
})
},
})
76 changes: 76 additions & 0 deletions layers/identity/server/utils/data-retention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/// <reference types="@cloudflare/workers-types" />

const DAY_SECONDS = 24 * 60 * 60

/**
* How long skilld.dev keeps records that name an account after they stop
* doing their job. Privacy is a product rule: a record with no further use is
* deleted, not archived.
*/
export const RETENTION_SECONDS = {
/** A revoked or expired CLI token stays visible on /me/devices for a week. */
endedCliToken: 7 * DAY_SECONDS,
/** Sign-in codes and device sessions expire within ten minutes. */
cliSignIn: DAY_SECONDS,
/** Delivery history and preference changes support three months of reporting. */
emailHistory: 90 * DAY_SECONDS,
} as const

export interface RetentionPurgeResult {
cliTokens: number
cliAuthCodes: number
cliDeviceSessions: number
digestRuns: number
weeklyRuns: number
emailPreferenceEvents: number
}

/**
* Delete personal records that are past their retention window.
*
* Each statement is idempotent, so a retried run deletes nothing twice.
* Digest runs are the one exception to a plain age cut: the newest sent or
* skipped run per account holds the cursor for the next digest, and an
* unresolved run still waits for a retry, so both stay.
*/
export async function purgeRetainedPersonalData(
db: D1Database,
now: number,
): Promise<RetentionPurgeResult> {
const endedTokenCutoff = now - RETENTION_SECONDS.endedCliToken
const signInCutoff = now - RETENTION_SECONDS.cliSignIn
const historyCutoff = now - RETENTION_SECONDS.emailHistory

const results = await db.batch([
db.prepare(
`DELETE FROM cli_tokens
WHERE revoked_at <= ?1
OR expires_at <= ?1`,
).bind(endedTokenCutoff),
db.prepare(`DELETE FROM cli_auth_codes WHERE created_at <= ?1`).bind(signInCutoff),
db.prepare(`DELETE FROM cli_device_sessions WHERE created_at <= ?1`).bind(signInCutoff),
db.prepare(
`DELETE FROM digest_runs
WHERE window_end <= ?1
AND status IN ('sent', 'skipped')
AND window_end < (
SELECT MAX(latest.window_end)
FROM digest_runs AS latest
WHERE latest.user_id = digest_runs.user_id
AND latest.status IN ('sent', 'skipped')
)`,
).bind(historyCutoff),
db.prepare(`DELETE FROM weekly_runs WHERE window_end <= ?1`).bind(historyCutoff),
db.prepare(`DELETE FROM email_preference_events WHERE occurred_at <= ?1`).bind(historyCutoff),
])

const changes = results.map(result => result.meta.changes)
return {
cliTokens: changes[0] ?? 0,
cliAuthCodes: changes[1] ?? 0,
cliDeviceSessions: changes[2] ?? 0,
digestRuns: changes[3] ?? 0,
weeklyRuns: changes[4] ?? 0,
emailPreferenceEvents: changes[5] ?? 0,
}
}
42 changes: 41 additions & 1 deletion layers/identity/server/utils/users.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { H3Event } from 'h3'
import { encryptToken } from './crypto'
import { decryptToken, encryptToken } from './crypto'

export interface UserRow {
id: number
Expand Down Expand Up @@ -140,3 +140,43 @@ export async function requireUserRow(event: H3Event): Promise<UserRow> {
throw createError({ statusCode: 401, message: 'User not found' })
return user
}

const GITHUB_SIGN_IN_REQUIRED = 'Your GitHub access ended when you signed out. Sign in with GitHub again.'

/**
* Delete the GitHub OAuth tokens stored for one account.
*
* Sign-out calls this, so a closed session leaves no usable GitHub credential
* behind. The next GitHub sign-in stores new tokens.
*/
export async function clearGithubUserCredentials(db: D1Database, userId: number): Promise<void> {
await db.prepare(
`UPDATE users
SET github_token_encrypted = NULL,
github_token_scopes = NULL,
github_token_expires_at = NULL,
github_refresh_token_encrypted = NULL,
github_refresh_token_expires_at = NULL,
github_token_client_id = NULL
WHERE id = ?1`,
).bind(userId).run()
}

/**
* Read the stored GitHub access token, or ask for a new GitHub sign-in.
*
* The token is absent after sign-out, so another open session must not fail
* with a generic error.
*/
export async function requireGithubUserToken(
db: D1Database,
userId: number,
tokenKey: string,
): Promise<string> {
const row = await db.prepare(
`SELECT github_token_encrypted FROM users WHERE id = ?1`,
).bind(userId).first<{ github_token_encrypted: string | null }>()
if (!row?.github_token_encrypted)
throw createError({ statusCode: 401, statusMessage: 'GitHub sign-in required', message: GITHUB_SIGN_IN_REQUIRED })
return await decryptToken(row.github_token_encrypted, tokenKey)
}
88 changes: 88 additions & 0 deletions layers/registry/server/utils/skill-md-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,92 @@ license: MIT
expect(parsed.html).toContain('/unknown')
expect(parsed.html).toContain('https://example.com/tdd')
})

describe('images', () => {
const ctx = {
owner: 'acme',
repo: 'skills',
name: 'diagnose',
branch: 'main',
skillDir: 'skills/diagnose',
filePath: '',
}

async function render(markdown: string): Promise<Document> {
const { html } = await parseSkillMd(markdown, ctx)
return new DOMParser().parseFromString(html, 'text/html')
}

it.each([
['a relative path', './diagram.png', 'https://github.com/acme/skills/raw/main/skills/diagnose/diagram.png'],
['raw GitHub content', 'https://raw.githubusercontent.com/acme/skills/main/a.png', 'https://raw.githubusercontent.com/acme/skills/main/a.png'],
['a GitHub raw path', 'https://github.com/acme/skills/raw/main/a.png', 'https://github.com/acme/skills/raw/main/a.png'],
['a GitHub attachment', 'https://github.com/user-attachments/assets/0a1b2c', 'https://github.com/user-attachments/assets/0a1b2c'],
['a GitHub user image', 'https://user-images.githubusercontent.com/1/a.png', 'https://user-images.githubusercontent.com/1/a.png'],
['a private GitHub user image', 'https://private-user-images.githubusercontent.com/1/a.png?jwt=x', 'https://private-user-images.githubusercontent.com/1/a.png?jwt=x'],
['a GitHub avatar', 'https://avatars.githubusercontent.com/u/1?v=4', 'https://avatars.githubusercontent.com/u/1?v=4'],
])('shows %s as an image that sends no referrer', async (_label, href, src) => {
const doc = await render(`![diagram](${href})`)

const img = doc.querySelector('img')
expect(img?.getAttribute('src')).toBe(src)
expect(img?.getAttribute('alt')).toBe('diagram')
expect(img?.getAttribute('referrerpolicy')).toBe('no-referrer')
expect(img?.getAttribute('loading')).toBe('lazy')
})

it.each([
['another host', 'https://tracker.example/pixel.gif'],
['plain http', 'http://raw.githubusercontent.com/acme/skills/main/a.png'],
['a protocol relative URL', '//tracker.example/pixel.gif'],
['a lookalike host', 'https://raw.git.xywcc.com.tracker.example/a.png'],
['a GitHub page that is not a raw file', 'https://github.com/acme/skills/issues/1'],
])('shows an image from %s as a link and never loads it', async (_label, href) => {
const doc = await render(`![status badge](${href})`)

expect(doc.querySelector('img')).toBeNull()
const link = doc.querySelector('a')
expect(link?.textContent).toBe('status badge')
expect(link?.getAttribute('href')).toBe(href.startsWith('//') ? `https:${href}` : href)
expect(link?.getAttribute('rel')).toBe('noopener noreferrer')
})

it('shows a blocked image inside a link as the link text', async () => {
const doc = await render('[![build](https://tracker.example/badge.svg)](https://ci.example/acme)')

expect(doc.querySelector('img')).toBeNull()
expect(doc.querySelectorAll('a')).toHaveLength(1)
expect(doc.querySelector('a')?.getAttribute('href')).toBe('https://ci.example/acme')
expect(doc.querySelector('a')?.textContent).toBe('build')
})

it('shows a blocked image with empty alt inside a link as visible text, not an empty anchor', async () => {
const doc = await render('[![](https://tracker.example/pixel.gif)](https://ci.example/acme)')

expect(doc.querySelector('img')).toBeNull()
expect(doc.querySelectorAll('a')).toHaveLength(1)
expect(doc.querySelector('a')?.getAttribute('href')).toBe('https://ci.example/acme')
expect(doc.querySelector('a')?.textContent).not.toBe('')
})
it.each([
['a mailto URL', 'mailto:badge@example.com'],
['a javascript URL', 'javascript:alert(1)'],
['a non-image data URL', 'data:text/plain,blocked'],
])('shows a scheme-blocked empty-alt image inside a link as visible text, not an empty anchor (%s)', async (_label, href) => {
const doc = await render(`[![](${href})](https://ci.example/acme)`)

expect(doc.querySelectorAll('a')).toHaveLength(1)
expect(doc.querySelector('a')?.getAttribute('href')).toBe('https://ci.example/acme')
expect(doc.querySelector('a')?.textContent?.trim()).not.toBe('')
})

it('shows a blocked image with whitespace-only alt inside a link as visible text, not an invisible anchor', async () => {
const doc = await render('[![ ](https://tracker.example/badge.svg)](https://ci.example/acme)')

expect(doc.querySelector('img')).toBeNull()
expect(doc.querySelectorAll('a')).toHaveLength(1)
expect(doc.querySelector('a')?.getAttribute('href')).toBe('https://ci.example/acme')
expect(doc.querySelector('a')?.textContent?.trim()).not.toBe('')
})
})
})
Loading
Loading