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
5 changes: 5 additions & 0 deletions COPY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ contradicts this file is a bug.

## Canonical assets

Skill validation in `/me` uses "Skill validation" and "Some of your Skills have validation issues."
The action is "View validation issues". Source links use "Open SKILL.md".
Email summaries use the subject "Your Skills have validation issues" and link to `/me?view=repositories`.
Missing optional fields are not issues. Claude Code extensions are portability notices.

Comparison navigation uses β€œCompare Humanizer, Stop Slop, and No AI Slop” on the homepage, writing track, and featured Skill pages.
The writing comparison credits β€œHarlan Wilton” as its author, with an agent research and drafting disclosure.

Expand Down
47 changes: 47 additions & 0 deletions layers/identity/app/pages/me/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
IdentitySubscriptionRef,
} from '../../../shared/contracts/account'
import type { StarsSyncResponse } from '../../utils/sync-starred-repos'
import { SKILL_VALIDATION_COPY } from '#shared/skill-validation-copy'
import { accountDeletionConfirmed } from '../../../shared/contracts/account'
import SkillgenRepositories from '../../components/_SkillgenRepositories.vue'
import { identityAccountQueries, identityAccountQueryOptions } from '../../queries/account'
Expand All @@ -27,6 +28,8 @@ interface LikedSkill {

const { data: me, error: accountError, status: accountStatus, refresh: retryAccount } = await useNuxtRpcQuery(identityAccountQueries.me(), identityAccountQueryOptions)
const { data: subs, error: subscriptionsError, status: subscriptionsStatus, refresh: retrySubscriptions } = await useNuxtRpcQuery(identityAccountQueries.subscriptions(), identityAccountQueryOptions)
const { data: validation, error: validationError, status: validationStatus, refresh: retryValidation } = await useNuxtRpcQuery(identityAccountQueries.validation())
const invalidSkillCount = computed(() => validation.value?.items.filter(item => item.issues.some(issue => issue.severity === 'error')).length ?? 0)
const {
data: likes,
error: likesError,
Expand Down Expand Up @@ -262,6 +265,14 @@ async function deleteAccount() {
</div>
</section>
<section v-else class="mx-auto w-full max-w-4xl py-6 sm:py-8">
<UAlert
v-if="invalidSkillCount && view !== 'repositories'"
color="warning"
variant="soft"
:title="SKILL_VALIDATION_COPY.summary"
class="mb-6"
:actions="[{ label: SKILL_VALIDATION_COPY.action, to: '/me?view=repositories', color: 'neutral', variant: 'outline', class: 'min-h-11' }]"
/>
<h1 v-if="view !== 'skills'" class="mb-8 text-2xl font-semibold tracking-tight sm:text-3xl">
{{ viewTitle }}
</h1>
Expand Down Expand Up @@ -493,6 +504,42 @@ async function deleteAccount() {
<SkillgenRepositories v-if="view === 'skillgen'" />

<section v-if="view === 'repositories'">
<div class="mb-6 border-b border-default pb-6">
<h2 class="text-lg font-semibold">
{{ SKILL_VALIDATION_COPY.heading }}
</h2>
<div v-if="validationError" class="mt-3" role="alert">
<p class="text-sm text-error">
Could not load Skill validation. Try again.
</p>
<UButton label="Retry" color="neutral" variant="outline" class="mt-3 min-h-11" :loading="validationStatus === 'pending'" @click="retryValidation()" />
</div>
<p v-else-if="validationStatus === 'pending' && !validation" class="mt-3 text-sm text-muted" role="status">
Checking your Skills
</p>
<div v-else-if="validation" class="mt-3">
<p class="text-sm text-muted">
{{ validation.checked }} {{ validation.checked === 1 ? 'Skill' : 'Skills' }} checked. GitHub forks are excluded.
</p>
<p v-if="validation.pending" class="mt-2 text-sm text-muted">
{{ validation.pending }} {{ validation.pending === 1 ? 'Skill awaits' : 'Skills await' }} source or GitHub metadata refresh.
</p>
<p v-if="!validation.items.length && validation.checked" class="mt-2 text-sm text-muted">
No frontmatter issues found in the checked Skills.
</p>
<details v-for="item in validation.items" :key="`${item.repository}/${item.name}`" class="mt-3 border-t border-default pt-3">
<summary class="min-h-11 cursor-pointer py-3 break-words font-mono text-sm">
{{ item.repository }}/{{ item.name }}
</summary>
<ul class="mt-3 space-y-2 pl-5 text-sm">
<li v-for="(issue, issueIndex) in item.issues" :key="`${issue.field}:${issueIndex}`" :class="issue.severity === 'error' ? 'text-error' : 'text-muted'">
{{ issue.message }}
</li>
</ul>
<a :href="item.sourceUrl" target="_blank" rel="noopener noreferrer" class="mt-3 inline-flex min-h-11 items-center text-sm text-primary underline">{{ SKILL_VALIDATION_COPY.source }}</a>
</details>
</div>
</div>
<p class="mt-2 text-sm leading-relaxed text-muted">
{{ watchedRepositoryCount }} {{ watchedRepositoryCount === 1 ? 'repository supports' : 'repositories support' }} your skill updates.
</p>
Expand Down
6 changes: 6 additions & 0 deletions layers/identity/app/queries/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
identityMutationResponseSchema,
identityOwnedRepoScanResponseSchema,
identityPrivacyPatchBodySchema,
identitySkillValidationSchema,
identitySubscriptionsSchema,
} from '../../shared/contracts/account'

Expand All @@ -27,6 +28,11 @@ export const identityAccountQueries = defineNuxtQueryGroup('identity:account', {
path: '/api/me/subscriptions',
response: identitySubscriptionsSchema,
}),
validation: () => defineNuxtRpcQuery({
key: ['identity', 'validation'],
path: '/api/me/validation',
response: identitySkillValidationSchema,
}),
saveEmail: () => defineNuxtRpcMutation({
body: identityEmailPatchBodySchema,
method: 'PATCH',
Expand Down
21 changes: 21 additions & 0 deletions layers/identity/server/api/me/email.patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { defineApiHandler } from '#shared/server/handler'
import { identityEmailPatchBodySchema, identityMutationResponseSchema } from '../../../shared/contracts/account'
import { authenticated } from '../../policies/authenticated'
import { planAccountSettings, updateAccountSettings } from '../../utils/account-settings'
import { sendEmailWithEnv, signUnsubToken } from '../../utils/email'
import { sendSkillValidationSummary } from '../../utils/skill-validation-email'
import { requireUserRow } from '../../utils/users'

export default defineApiHandler({
Expand All @@ -22,6 +24,25 @@ export default defineApiHandler({
if (plan._tag === 'MissingAddress')
throw createError({ statusCode: 400, message: 'Add a valid email address to receive emails' })
await updateAccountSettings(platform.db, u.id, plan.columns)
const config = useRuntimeConfig(event)
const updated = await requireUserRow(event)
const summary = sendSkillValidationSummary({
db: platform.db,
user: updated,
now: Math.floor(Date.now() / 1000),
signUnsubscribe: userId => signUnsubToken(userId, config.tokenKey),
send: input => sendEmailWithEnv(platform.env, { ...input, from: config.email.from }),
}).then((outcome) => {
if (outcome === 'rejected' || outcome === 'uncertain')
emitOperationalEvent(createWideEvent({ operation: 'skill-validation-email', outcome: 'failed' }))
}).catch(() => {
emitOperationalEvent(createWideEvent({ operation: 'skill-validation-email', outcome: 'failed' }))
})
const context = (event.context as { cloudflare?: { context?: { waitUntil?: (p: Promise<unknown>) => void } } }).cloudflare?.context
if (context?.waitUntil)
context.waitUntil(summary)
else
await summary
const current = session as unknown as UserSession | null
if (current?.user && (body.email_opt_in !== undefined || body.weekly_opt_in !== undefined)) {
await setUserSession(event, {
Expand Down
16 changes: 16 additions & 0 deletions layers/identity/server/api/me/validation.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineApiHandler } from '#shared/server/handler'
import { identitySkillValidationSchema } from '../../../shared/contracts/account'
import { authenticated } from '../../policies/authenticated'
import { skillValidationPresenter } from '../../presenters/skill-validation'
import { loadAccountSkillValidation } from '../../utils/skill-validation'
import { requireUserRow } from '../../utils/users'

export default defineApiHandler({
policy: [authenticated],
response: identitySkillValidationSchema,
presenter: skillValidationPresenter,
handler: async ({ event, platform }) => {
const user = await requireUserRow(event)
return loadAccountSkillValidation(platform.db, user.login)
},
})
5 changes: 5 additions & 0 deletions layers/identity/server/presenters/skill-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { AccountSkillValidation } from '../utils/skill-validation'

export function skillValidationPresenter(result: AccountSkillValidation): AccountSkillValidation {
return result
}
27 changes: 26 additions & 1 deletion layers/identity/server/routes/auth/github.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import type { H3Event } from 'h3'
import { useSession } from 'h3'
import { z } from 'zod'
import { parseReturnTo } from '#shared/return-to'
import { sendEmailWithEnv, signUnsubToken } from '../../utils/email'
import { fetchVerifiedPrimaryEmail } from '../../utils/github-emails'
import { ownedRepoScanWarning, scanOwnedRepos } from '../../utils/scan-owned-repos'
import { sendSkillValidationSummary } from '../../utils/skill-validation-email'
import { upsertUserFromGithub } from '../../utils/users'
import { handleWatchAction } from '../../utils/watch-actions'

Expand Down Expand Up @@ -62,6 +64,19 @@ const githubHandler = defineOAuthGitHubEventHandler({
clientId: platform.env.NUXT_OAUTH_GITHUB_CLIENT_ID,
scopes,
})
const config = useRuntimeConfig(event)
const sendValidationSummary = () => sendSkillValidationSummary({
db: platform.db,
user: row,
now: Math.floor(Date.now() / 1000),
signUnsubscribe: userId => signUnsubToken(userId, config.tokenKey),
send: input => sendEmailWithEnv(platform.env, { ...input, from: config.email.from }),
}).then((outcome) => {
if (outcome === 'rejected' || outcome === 'uncertain')
emitOperationalEvent(createWideEvent({ operation: 'skill-validation-email', outcome: 'failed' }))
}).catch(() => {
emitOperationalEvent(createWideEvent({ operation: 'skill-validation-email', outcome: 'failed' }))
})

// First sign-in: scan the account's public repositories for SKILL.md and
// index what it finds. This is how the registry grows, and the files are
Expand All @@ -76,7 +91,7 @@ const githubHandler = defineOAuthGitHubEventHandler({
}).then((result) => {
if (ownedRepoScanWarning(result))
emitOperationalEvent(createWideEvent({ operation: 'oauth-owned-repo-scan', outcome: 'incomplete' }))
}).catch(() => {
}).then(sendValidationSummary).catch(() => {
emitOperationalEvent(createWideEvent({ operation: 'oauth-owned-repo-scan', outcome: 'failed' }))
})
const cfCtx = (event.context as { cloudflare?: { context?: { waitUntil?: (p: Promise<unknown>) => void } } }).cloudflare?.context
Expand All @@ -85,6 +100,16 @@ const githubHandler = defineOAuthGitHubEventHandler({
else
void scanPromise
}
else {
const summaryPromise = sendValidationSummary().catch(() => {
emitOperationalEvent(createWideEvent({ operation: 'skill-validation-email', outcome: 'failed' }))
})
const cfCtx = (event.context as { cloudflare?: { context?: { waitUntil?: (p: Promise<unknown>) => void } } }).cloudflare?.context
if (cfCtx?.waitUntil)
cfCtx.waitUntil(summaryPromise)
else
await summaryPromise
}

const intent = await loginIntentSession(event)
const action = typeof intent.data.action === 'string' ? intent.data.action : ''
Expand Down
1 change: 1 addition & 0 deletions layers/identity/server/utils/account-deletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const ACCOUNT_ROW_DELETES = [
`DELETE FROM digest_runs WHERE user_id = ?1`,
`DELETE FROM weekly_runs WHERE user_id = ?1`,
`DELETE FROM email_preference_events WHERE user_id = ?1`,
`DELETE FROM skill_validation_email_deliveries WHERE user_id = ?1`,
`DELETE FROM artifact_resolution_requesters WHERE account_id = ?1`,
`DELETE FROM artifact_download_grants WHERE account_id = ?1`,
`DELETE FROM private_artifact_attestations WHERE account_id = ?1`,
Expand Down
76 changes: 76 additions & 0 deletions layers/identity/server/utils/skill-validation-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { SendEmailInput, SendEmailResult } from './email'
import type { UserRow } from './users'
import { SKILL_VALIDATION_COPY } from '#shared/skill-validation-copy'
import { digestEmailHeaders } from './email'
import { loadAccountSkillValidation } from './skill-validation'
import { weeklyDeliveryActive } from './weekly-select'

interface ValidationEmailInput {
db: D1Database
user: UserRow
send: (input: SendEmailInput) => Promise<SendEmailResult>
now: number
signUnsubscribe: (userId: number) => Promise<string>
}

const CLAIM_LEASE_SECONDS = 300

/** One consented connection summary, claimed atomically before delivery. */
export async function sendSkillValidationSummary(input: ValidationEmailInput): Promise<'skipped' | SendEmailResult['_tag']> {
const { db, user, send, now } = input
if (!user.repo_indexing || (!user.email_opt_in && !weeklyDeliveryActive(user)))
return 'skipped'
const to = (user.digest_email ?? user.email ?? '').trim()
if (!to)
return 'skipped'
const prior = await db.prepare(`SELECT status, attempted_at FROM skill_validation_email_deliveries WHERE user_id = ?1`)
.bind(user.id)
.first<{ status: string, attempted_at: number }>()
if (prior && (prior.status === 'accepted' || prior.status === 'uncertain'
|| (prior.status === 'claimed' && prior.attempted_at >= now - CLAIM_LEASE_SECONDS))) {
return 'skipped'
}
const summary = await loadAccountSkillValidation(db, user.login)
const invalid = summary.items.filter(item => item.issues.some(issue => issue.severity === 'error'))
if (!invalid.length)
return 'skipped'
const token = await input.signUnsubscribe(user.id)
const list = user.email_opt_in ? 'digest' : 'weekly'
const unsubscribeUrl = `https://skilld.dev/api/unsubscribe?t=${encodeURIComponent(token)}&list=${list}`
const claimed = await db.prepare(`
INSERT INTO skill_validation_email_deliveries (user_id, status, attempted_at)
VALUES (?1, 'claimed', ?2)
ON CONFLICT(user_id) DO UPDATE SET status = 'claimed', attempted_at = excluded.attempted_at, error = NULL
WHERE skill_validation_email_deliveries.status = 'rejected'
OR (skill_validation_email_deliveries.status = 'claimed' AND skill_validation_email_deliveries.attempted_at < ?3)
RETURNING user_id
`).bind(user.id, now, now - CLAIM_LEASE_SECONDS).first<{ user_id: number }>()
if (!claimed)
return 'skipped'
// Only pre-send claims can expire. Fence old workers before dispatch.
// A crash after this transition leaves ambiguous delivery blocked.
const dispatch = await db.prepare(`UPDATE skill_validation_email_deliveries
SET status = 'uncertain', error = 'Delivery started without a confirmed receipt'
WHERE user_id = ?1 AND attempted_at = ?2 AND status = 'claimed'
RETURNING user_id`).bind(user.id, now).first<{ user_id: number }>()
if (!dispatch)
return 'skipped'
const link = 'https://skilld.dev/me?view=repositories'
const count = `${invalid.length} ${invalid.length === 1 ? 'Skill has' : 'Skills have'} frontmatter issues.`
const outcome = await send({
to,
subject: SKILL_VALIDATION_COPY.subject,
html: `<h1>${SKILL_VALIDATION_COPY.subject}</h1><p>${count}</p><p><a href="${link}">${SKILL_VALIDATION_COPY.action}</a></p>`,
text: `${count}\n\n${SKILL_VALIDATION_COPY.action}: ${link}`,
headers: digestEmailHeaders(unsubscribeUrl, `skilld-validation:${user.id}`),
}).catch((error): SendEmailResult => ({
// A thrown send may have reached the provider. Do not risk duplicate delivery.
_tag: 'uncertain',
error: error instanceof Error ? error.message : String(error),
}))
await db.prepare(`UPDATE skill_validation_email_deliveries SET status = ?1, error = ?2
WHERE user_id = ?3 AND attempted_at = ?4 AND status = 'uncertain'`)
.bind(outcome._tag, outcome._tag === 'accepted' ? null : outcome.error, user.id, now)
.run()
return outcome._tag
}
9 changes: 9 additions & 0 deletions layers/identity/server/utils/skill-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { RepositorySkillValidation } from '#shared/server/skill-validation-sources'
import { loadRepositorySkillValidation } from '#shared/server/skill-validation-sources'

export type AccountSkillValidation = RepositorySkillValidation

/** Only the signed-in account's own Repository sources enter this summary. */
export function loadAccountSkillValidation(db: D1Database, login: string): Promise<AccountSkillValidation> {
return loadRepositorySkillValidation(db, { _tag: 'owner', owner: login })
}
15 changes: 15 additions & 0 deletions layers/identity/shared/contracts/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ export const identityMutationResponseSchema = z.object({
ok: z.literal(true),
})

export const identitySkillValidationSchema = z.object({
checked: z.number().int().nonnegative(),
pending: z.number().int().nonnegative(),
items: z.array(z.object({
repository: z.string(),
name: z.string(),
sourceUrl: z.string().url(),
issues: z.array(z.object({
field: z.string(),
severity: z.enum(['error', 'notice']),
message: z.string(),
})),
})),
})

/**
* Account deletion asks for the GitHub login, typed by hand. A login that does
* not match deletes nothing.
Expand Down
1 change: 1 addition & 0 deletions layers/registry/server/utils/repository-move.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ const REPOS_COLUMNS = [
'default_branch',
'stars',
'forks',
'is_fork',
'pushed_at',
'repo_created_at',
'repo_meta_synced_at',
Expand Down
10 changes: 7 additions & 3 deletions layers/registry/server/utils/sync-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ function markRepoSummaryCheckedStatement(
SET default_branch = ?,
stars = ?,
forks = ?,
is_fork = ?,
description = ?,
pushed_at = ?,
repo_created_at = ?,
Expand All @@ -544,6 +545,7 @@ function markRepoSummaryCheckedStatement(
meta.default_branch || 'main',
meta.stargazers_count ?? 0,
meta.forks_count ?? 0,
typeof meta.fork === 'boolean' ? Number(meta.fork) : null,
meta.description?.trim() || null,
pushedAt,
epoch(meta.created_at),
Expand Down Expand Up @@ -1011,8 +1013,8 @@ export async function syncRepo(
`INSERT INTO repos (
owner, repo, default_branch, stars, forks, description, pushed_at, repo_created_at,
repo_meta_synced_at, last_tree_sha, repo_kind, repo_kind_source,
repo_skill_count, broken_since, source_owner, source_repo, repository_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
repo_skill_count, broken_since, source_owner, source_repo, repository_id, is_fork
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)
ON CONFLICT(owner, repo) DO UPDATE SET
default_branch = excluded.default_branch,
stars = excluded.stars,
Expand All @@ -1029,7 +1031,8 @@ export async function syncRepo(
tree_truncated_at = NULL,
source_owner = excluded.source_owner,
source_repo = excluded.source_repo,
repository_id = excluded.repository_id`,
repository_id = excluded.repository_id,
is_fork = excluded.is_fork`,
).bind(
owner,
repo,
Expand All @@ -1047,6 +1050,7 @@ export async function syncRepo(
sourceOwner,
sourceRepo,
repositoryId,
typeof meta.fork === 'boolean' ? Number(meta.fork) : null,
)

if (skillFiles.length === 0) {
Expand Down
Loading
Loading