From 93c14da5abfd38d8b74aa596bb38cb0c1c46658c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 17 Sep 2026 01:38:21 +1000 Subject: [PATCH 1/5] feat(identity): delete an account and every row that names it Nobody could remove a skilld account or the data tied to it. Several account tables carry no foreign key, so deleting the users row alone would leave email history and tokens behind. The test reads the migrated schema. A future table that names an account fails it until account deletion clears that table too. --- layers/identity/app/pages/me/index.vue | 149 ++++++ layers/identity/server/api/me/index.delete.ts | 53 ++ .../server/policies/browser-session.ts | 9 + .../identity/server/utils/account-deletion.ts | 161 ++++++ layers/identity/shared/contracts/account.ts | 21 + test/unit/account-deletion.test.ts | 488 ++++++++++++++++++ 6 files changed, 881 insertions(+) create mode 100644 layers/identity/server/api/me/index.delete.ts create mode 100644 layers/identity/server/policies/browser-session.ts create mode 100644 layers/identity/server/utils/account-deletion.ts create mode 100644 test/unit/account-deletion.test.ts diff --git a/layers/identity/app/pages/me/index.vue b/layers/identity/app/pages/me/index.vue index 2bdbe497..73f59125 100644 --- a/layers/identity/app/pages/me/index.vue +++ b/layers/identity/app/pages/me/index.vue @@ -1,11 +1,14 @@ diff --git a/layers/identity/server/api/me/index.delete.ts b/layers/identity/server/api/me/index.delete.ts new file mode 100644 index 00000000..586936e9 --- /dev/null +++ b/layers/identity/server/api/me/index.delete.ts @@ -0,0 +1,53 @@ +import { defineApiHandler } from '#shared/server/handler' +import { + accountDeletionConfirmed, + identityAccountDeleteBodySchema, + identityAccountDeleteResponseSchema, +} from '../../../shared/contracts/account' +import { browserSession } from '../../policies/browser-session' +import { deleteAccountData, loadGithubGrant, revokeGithubGrant } from '../../utils/account-deletion' +import { requireUserRow } from '../../utils/users' + +/** + * Delete the signed-in account. + * + * The request must carry the sign-in cookie and the GitHub login typed by the + * person. Local data goes first, in one transaction. The GitHub grant is + * revoked after that, and a failed revocation still returns success, because + * the account no longer exists. + */ +export default defineApiHandler({ + schema: identityAccountDeleteBodySchema, + policy: [browserSession], + response: identityAccountDeleteResponseSchema, + handler: async ({ event, body, platform }) => { + const account = await requireUserRow(event) + if (!accountDeletionConfirmed(body.confirm_login, account.login)) { + throw createError({ + statusCode: 400, + statusMessage: 'Confirmation does not match', + message: `Type ${account.login} to delete your account.`, + }) + } + + const { db, env } = platform + // The token lives on the row this request deletes, so read it first. + const grant = await loadGithubGrant(db, account.id, env.NUXT_TOKEN_KEY) + const deletedRows = await deleteAccountData(db, account.id, Math.floor(Date.now() / 1000)) + const revocation = await revokeGithubGrant(grant, { + clientId: env.NUXT_OAUTH_GITHUB_CLIENT_ID, + clientSecret: env.NUXT_OAUTH_GITHUB_CLIENT_SECRET, + }, fetch) + + emitOperationalEvent(createWideEvent({ + 'operation': 'account-deletion', + 'outcome': revocation._tag, + 'reason': revocation._tag === 'skipped' ? revocation.reason : undefined, + 'upstream.status': revocation._tag === 'failed' ? revocation.status : undefined, + 'success.count': deletedRows, + }), revocation._tag === 'revoked' ? 'info' : 'warn') + + await clearUserSession(event) + return { ok: true as const, github_access_revoked: revocation._tag === 'revoked' } + }, +}) diff --git a/layers/identity/server/policies/browser-session.ts b/layers/identity/server/policies/browser-session.ts new file mode 100644 index 00000000..a1d0fdb4 --- /dev/null +++ b/layers/identity/server/policies/browser-session.ts @@ -0,0 +1,9 @@ +import type { Policy } from '#shared/server/handler' + +/** + * The request carries a skilld.dev sign-in cookie. + * + * A CLI bearer token alone does not pass. A token copied off a machine must + * not be able to delete the account it belongs to. + */ +export const browserSession: Policy = ({ session }) => typeof session?.user?.id === 'number' diff --git a/layers/identity/server/utils/account-deletion.ts b/layers/identity/server/utils/account-deletion.ts new file mode 100644 index 00000000..102fa64b --- /dev/null +++ b/layers/identity/server/utils/account-deletion.ts @@ -0,0 +1,161 @@ +/// +import { decryptToken } from './crypto' + +/** + * Every statement that removes a row naming one account, children first. + * + * Each table is listed even when a foreign key would cascade from `users`: + * several tables carry no foreign key at all, and the deletion must not depend + * on foreign key enforcement. `test/unit/account-deletion.test.ts` checks the + * migrated schema, so a new table that names an account fails there until it + * joins this list. + * + * Skills indexed from the account's public Repositories stay. GitHub is their + * source, and discovery indexes public Repositories without an account. + * Collections are the account's own writing on skilld.dev, so they go. + */ +const ACCOUNT_ROW_DELETES = [ + `DELETE FROM skill_likes WHERE user_id = ?1`, + `DELETE FROM skill_subscriptions WHERE user_id = ?1`, + `DELETE FROM user_starred_repos WHERE user_id = ?1`, + `DELETE FROM collection_skills_v2 + WHERE collection_id IN (SELECT id FROM collections_v2 WHERE author_user_id = ?1)`, + `DELETE FROM collections_v2 WHERE author_user_id = ?1`, + `DELETE FROM cli_tokens WHERE user_id = ?1`, + `DELETE FROM cli_auth_codes WHERE user_id = ?1`, + `DELETE FROM cli_device_sessions WHERE user_id = ?1`, + `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 artifact_download_grants WHERE account_id = ?1`, + `DELETE FROM private_artifact_attestations WHERE account_id = ?1`, + `DELETE FROM private_artifacts WHERE account_id = ?1`, + `DELETE FROM private_artifact_keys WHERE account_id = ?1`, + `DELETE FROM artifact_check_results + WHERE resolution_id IN (SELECT id FROM artifact_resolutions WHERE account_id = ?1)`, + `DELETE FROM artifact_attestations + WHERE resolution_id IN (SELECT id FROM artifact_resolutions WHERE account_id = ?1)`, + `DELETE FROM artifact_resolutions WHERE account_id = ?1`, + `DELETE FROM github_app_repositories + WHERE installation_id IN (SELECT installation_id FROM github_app_installations WHERE account_id = ?1)`, + `DELETE FROM github_app_installations WHERE account_id = ?1`, + `DELETE FROM github_app_user_authorizations WHERE account_id = ?1`, + `DELETE FROM users WHERE id = ?1`, +] as const + +/** + * Delete one account and every row that names it. Returns the deleted row count. + * + * D1 runs a batch as one transaction, so a failed delete keeps the whole + * account and the person can try again. The like and curator counters on + * `skills` include this account, so each affected Skill joins the queue that + * drain-skill-dirty recomputes. + */ +export async function deleteAccountData(db: D1Database, accountId: number, now: number): Promise { + const recounts = [ + db.prepare( + `INSERT OR REPLACE INTO skill_dirty (owner, repo, name, reason, queued_at, attempts) + SELECT owner, repo, name, 'like', ?2, 0 + FROM skill_likes + WHERE user_id = ?1`, + ).bind(accountId, now), + db.prepare( + `INSERT OR REPLACE INTO skill_dirty (owner, repo, name, reason, queued_at, attempts) + SELECT cs.owner, cs.repo, cs.name, 'curator', ?2, 0 + FROM collection_skills_v2 AS cs + JOIN collections_v2 AS c ON c.id = cs.collection_id + WHERE c.author_user_id = ?1 + AND c.deleted_at IS NULL + AND cs.name IS NOT NULL`, + ).bind(accountId, now), + ] + const deletes = ACCOUNT_ROW_DELETES.map(sql => db.prepare(sql).bind(accountId)) + + const results = await db.batch([...recounts, ...deletes]) + return results + .slice(recounts.length) + .reduce((total, result) => total + result.meta.changes, 0) +} + +/** The GitHub sign-in token stored for an account, read before its row goes. */ +export type StoredGithubGrant + = | { _tag: 'stored', clientId: string, accessToken: string } + | { _tag: 'absent' } + | { _tag: 'unreadable' } + +export async function loadGithubGrant( + db: D1Database, + accountId: number, + tokenKey: string, +): Promise { + const row = await db.prepare( + `SELECT github_token_encrypted, github_token_client_id FROM users WHERE id = ?1`, + ).bind(accountId).first<{ github_token_encrypted: string | null, github_token_client_id: string | null }>() + // Sign-out deletes the token, so a signed-out browser leaves nothing to revoke. + if (!row?.github_token_encrypted || !row.github_token_client_id) + return { _tag: 'absent' } + + const clientId = row.github_token_client_id + return decryptToken(row.github_token_encrypted, tokenKey).then( + (accessToken): StoredGithubGrant => ({ _tag: 'stored', clientId, accessToken }), + // A token sealed with a retired key cannot be read. Account deletion must + // still go ahead, so the caller reports the grant as not revoked. + (): StoredGithubGrant => ({ _tag: 'unreadable' }), + ) +} + +export interface GithubOAuthClient { + clientId: string + clientSecret: string +} + +export type GithubGrantRevocation + = | { _tag: 'revoked' } + | { _tag: 'skipped', reason: 'no-token' | 'unreadable-token' | 'no-client-secret' | 'other-client' } + | { _tag: 'failed', status: number | null } + +const GITHUB_REVOKE_TIMEOUT_MS = 10_000 + +/** + * Ask GitHub to delete the OAuth grant, which revokes every token skilld holds + * for the person, including tokens on other devices. + * + * GitHub accepts only the credentials of the OAuth app that issued the token. + * Every other outcome is a value: the account is already deleted when this + * runs, and the person can still revoke skilld in their GitHub settings. + */ +export async function revokeGithubGrant( + grant: StoredGithubGrant, + client: GithubOAuthClient, + fetcher: typeof globalThis.fetch, +): Promise { + if (grant._tag === 'absent') + return { _tag: 'skipped', reason: 'no-token' } + if (grant._tag === 'unreadable') + return { _tag: 'skipped', reason: 'unreadable-token' } + if (!client.clientId || !client.clientSecret) + return { _tag: 'skipped', reason: 'no-client-secret' } + if (grant.clientId !== client.clientId) + return { _tag: 'skipped', reason: 'other-client' } + + const url = `https://api.github.com/applications/${encodeURIComponent(client.clientId)}/grant` + return fetcher(url, { + method: 'DELETE', + redirect: 'manual', + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Basic ${btoa(`${client.clientId}:${client.clientSecret}`)}`, + 'Content-Type': 'application/json', + 'User-Agent': 'skilld.dev', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify({ access_token: grant.accessToken }), + signal: AbortSignal.timeout(GITHUB_REVOKE_TIMEOUT_MS), + }).then( + (response): GithubGrantRevocation => response.status === 204 + ? { _tag: 'revoked' } + : { _tag: 'failed', status: response.status }, + // Network failure or timeout. The status is unknown, not a rejection. + (): GithubGrantRevocation => ({ _tag: 'failed', status: null }), + ) +} diff --git a/layers/identity/shared/contracts/account.ts b/layers/identity/shared/contracts/account.ts index 2fa311d4..a6448be0 100644 --- a/layers/identity/shared/contracts/account.ts +++ b/layers/identity/shared/contracts/account.ts @@ -52,8 +52,29 @@ export const identityMutationResponseSchema = z.object({ ok: z.literal(true), }) +/** + * Account deletion asks for the GitHub login, typed by hand. A login that does + * not match deletes nothing. + */ +export const identityAccountDeleteBodySchema = z.object({ + confirm_login: z.string().trim().min(1).max(100), +}) + +export const identityAccountDeleteResponseSchema = z.object({ + ok: z.literal(true), + /** False when skilld may still appear in the person's GitHub authorized apps. */ + github_access_revoked: z.boolean(), +}) + +/** GitHub logins ignore case, so the confirmation does too. */ +export function accountDeletionConfirmed(typedLogin: string, login: string): boolean { + return typedLogin.trim().toLowerCase() === login.toLowerCase() +} + export type IdentityEmailPatchBody = z.input export type IdentityMutationResponse = z.output +export type IdentityAccountDeleteBody = z.input +export type IdentityAccountDeleteResponse = z.output export interface IdentitySubscriptionRef { owner: string diff --git a/test/unit/account-deletion.test.ts b/test/unit/account-deletion.test.ts new file mode 100644 index 00000000..c5dd8842 --- /dev/null +++ b/test/unit/account-deletion.test.ts @@ -0,0 +1,488 @@ +import type { EventHandler, H3Event } from 'h3' +import type { DatabaseSync, SQLInputValue } from 'node:sqlite' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { deleteAccountData, loadGithubGrant, revokeGithubGrant } from '../../layers/identity/server/utils/account-deletion' +import { issueSession } from '../../layers/identity/server/utils/cli-tokens' +import { encryptToken } from '../../layers/identity/server/utils/crypto' +import { accountDeletionConfirmed } from '../../layers/identity/shared/contracts/account' +import { emitOperationalEvent } from '../../server/utils/operational-event' +import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' + +const NOW = 1_789_603_200 +const DAY = 86_400 +const TOKEN_KEY = btoa(String.fromCharCode(...new Uint8Array(32).fill(7))) +const OTHER_TOKEN_KEY = btoa(String.fromCharCode(...new Uint8Array(32).fill(8))) +const CLIENT = { clientId: 'skilld-oauth-client', clientSecret: 'skilld-oauth-secret' } + +interface Account { + id: number + login: string + /** One hex digit that keeps unique columns unique between accounts. */ + hex: string +} + +const OCTOCAT: Account = { id: 7001, login: 'octocat', hex: 'a' } +const HUBOT: Account = { id: 7002, login: 'hubot', hex: 'b' } + +/** + * Columns that name an account but that account deletion leaves alone. + * Every other column found by `accountColumns` must be cleared. + */ +const KEPT_ACCOUNT_COLUMNS = { + 'jobs.user_id': 'cf-jobs column. No job payload in this codebase carries a userId.', + 'failed_jobs.user_id': 'cf-jobs column. No job payload in this codebase carries a userId.', + 'job_batches.user_id': 'cf-jobs column. No job batch in this codebase carries a userId.', + 'weekly_click_events.user_id': 'Migration 0118 (#240) drops this table and every row in it.', +} satisfies Record + +const ACCOUNT_COLUMN_NAMES = new Set(['user_id', 'account_id', 'author_user_id']) + +/** + * Every `table.column` in the migrated schema that holds an account id: a + * foreign key to `users`, or a column named like one. + */ +function accountColumns(raw: DatabaseSync): string[] { + const tables = raw.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all() as Array<{ name: string }> + const columns = tables.flatMap(({ name: table }) => { + const named = (raw.prepare(`SELECT name FROM pragma_table_info(?)`).all(table) as Array<{ name: string }>) + .map(column => column.name) + .filter(column => ACCOUNT_COLUMN_NAMES.has(column)) + const referencing = (raw.prepare(`SELECT "from" AS name FROM pragma_foreign_key_list(?) WHERE "table" = 'users'`).all(table) as Array<{ name: string }>) + .map(column => column.name) + return [...new Set([...named, ...referencing])].map(column => `${table}.${column}`) + }) + return ['users.id', ...columns].sort() +} + +function rowsNaming(raw: DatabaseSync, column: string, accountId: number): number { + const [table, name] = column.split('.') + const row = raw.prepare(`SELECT COUNT(*) AS count FROM "${table}" WHERE "${name}" = ?`).get(accountId) as { count: number } + return row.count +} + +function seedAccount(raw: DatabaseSync, account: Account, githubToken: string | null = null): void { + const run = (sql: string, ...values: SQLInputValue[]) => raw.prepare(sql).run(...values) + const { id, login, hex } = account + const sha = hex.repeat(64) + const artifactId = `sha256:${sha}` + const resolutionId = `resolution-${login}` + const installationId = 600_000 + id + const repositoryId = 500_000 + id + + run( + `INSERT INTO users ( + id, github_id, login, name, email, digest_email, avatar, + github_token_encrypted, github_token_scopes, github_token_client_id, + email_opt_in, created_at, last_login_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'read:user user:email', ?, 1, ?, ?)`, + id, + 900_000 + id, + login, + `${login} name`, + `${login}@example.com`, + `${login}@example.com`, + `https://avatars.githubusercontent.com/u/${id}`, + githubToken, + githubToken ? CLIENT.clientId : null, + NOW - 30 * DAY, + NOW, + ) + run(`INSERT INTO skill_likes (user_id, owner, repo, name, created_at) VALUES (?, 'acme', 'skills', 'diagnose', ?)`, id, NOW) + run(`INSERT INTO skill_subscriptions (user_id, owner, repo, source, created_at) VALUES (?, 'acme', 'skills', 'like', ?)`, id, NOW) + run(`INSERT INTO user_starred_repos (user_id, owner, repo, starred_at, has_skill) VALUES (?, 'acme', 'skills', ?, 1)`, id, NOW) + const collectionId = run( + `INSERT INTO collections_v2 (author_user_id, slug, name, created_at, updated_at) VALUES (?, 'stack', 'Stack', ?, ?)`, + id, + NOW, + NOW, + ).lastInsertRowid + run( + `INSERT INTO collection_skills_v2 (collection_id, position, owner, repo, name, reason) + VALUES (?, 0, 'acme', 'skills', 'tdd', 'Writes the failing test before the fix.')`, + collectionId, + ) + run(`INSERT INTO collection_skills_v2 (collection_id, position, owner, repo, name) VALUES (?, 1, 'acme', 'docs', NULL)`, collectionId) + run( + `INSERT INTO cli_tokens (user_id, refresh_hash, kind, device_label, created_at, last_used_at) + VALUES (?, ?, 'pat', 'laptop', ?, ?)`, + id, + `refresh-${login}`, + NOW, + NOW, + ) + run( + `INSERT INTO cli_auth_codes (code, user_id, code_challenge, redirect_port, state, created_at, expires_at) + VALUES (?, ?, 'challenge', 49152, 'state', ?, ?)`, + `code-${login}`, + id, + NOW, + NOW + 600, + ) + run( + `INSERT INTO cli_device_sessions (device_code, user_code, user_id, machine_hint, status, created_at, expires_at) + VALUES (?, ?, ?, 'darwin-arm64', 'authorized', ?, ?)`, + `device-${login}`, + `CODE-${login}`, + id, + NOW, + NOW + 600, + ) + run( + `INSERT INTO digest_runs ( + user_id, delivery_key, window_start, window_end, cursor_start, cursor_end, + status, claim_token, claimed_at, finished_at + ) VALUES (?, ?, ?, ?, 0, 5, 'skipped', 'claim', ?, ?)`, + id, + `digest-${login}`, + NOW - 30 * DAY, + NOW - DAY, + NOW - DAY, + NOW - DAY, + ) + run( + `INSERT INTO weekly_runs (user_id, window_start, window_end, status, claimed_at) VALUES (?, ?, ?, 'skipped', ?)`, + id, + NOW - 7 * DAY, + NOW, + NOW, + ) + run(`INSERT INTO email_preference_events (user_id, list, action, occurred_at) VALUES (?, 'weekly', 'unsubscribed', ?)`, id, NOW) + + run( + `INSERT INTO github_app_installations (installation_id, account_id, github_account_id, state, connected_at, verified_at) + VALUES (?, ?, ?, 'active', ?, ?)`, + installationId, + id, + 800_000 + id, + NOW, + NOW, + ) + run( + `INSERT INTO github_app_repositories (installation_id, repository_id, owner, repository, visibility, state, selected_at) + VALUES (?, ?, ?, 'private-skills', 'private', 'selected', ?)`, + installationId, + repositoryId, + login, + NOW, + ) + run( + `INSERT INTO github_app_user_authorizations ( + account_id, access_token_encrypted, access_token_expires_at, refresh_token_encrypted, + refresh_token_expires_at, client_id, authorized_at, updated_at + ) VALUES (?, 'sealed-access', ?, 'sealed-refresh', ?, 'github-app-client', ?, ?)`, + id, + NOW + 3600, + NOW + 30 * DAY, + NOW, + NOW, + ) + run( + `INSERT INTO artifact_resolutions ( + id, request_fingerprint, state, requested_owner, requested_repository, selector_type, + selector_value, repository_id, visibility, account_id, github_installation_id + ) VALUES (?, ?, 'ready', ?, 'private-skills', 'named-skill', 'deploy', ?, 'private', ?, ?)`, + resolutionId, + `fingerprint-${login}`, + login, + repositoryId, + id, + installationId, + ) + run(`INSERT INTO artifact_check_results (resolution_id, name, version, outcome, required) VALUES (?, 'skill-structure', '1', 'pass', 1)`, resolutionId) + run( + `INSERT INTO artifacts (id, content_sha256, content_bytes, format, r2_key) VALUES (?, ?, 128, 'skilld-tar-v1', ?)`, + artifactId, + sha, + `artifacts/${login}`, + ) + run(`INSERT INTO artifact_attestations (resolution_id, artifact_id, attestation_json) VALUES (?, ?, '{}')`, resolutionId, artifactId) + run( + `INSERT INTO private_artifacts ( + account_id, artifact_id, resolution_id, repository_id, content_sha256, content_bytes, + ciphertext_sha256, ciphertext_bytes, r2_key, encryption_key_id, delivery_status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 128, ?, 156, ?, ?, 'available', ?, ?)`, + id, + artifactId, + resolutionId, + repositoryId, + sha, + sha, + `private/${login}`, + `key-${login}`, + NOW, + NOW, + ) + run( + `INSERT INTO private_artifact_attestations (resolution_id, account_id, artifact_id, attestation_json, created_at) + VALUES (?, ?, ?, '{}', ?)`, + resolutionId, + id, + artifactId, + NOW, + ) + run( + `INSERT INTO private_artifact_keys (account_id, key_id, wrapped_key, wrap_key_id, wrap_algorithm, state, created_at) + VALUES (?, ?, 'wrapped', 'wrap-primary', 'A256KW', 'active', ?)`, + id, + `key-${login}`, + NOW, + ) + run( + `INSERT INTO artifact_download_grants (token_hash, account_id, artifact_id, resolution_id, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + 'f'.repeat(63) + hex, + id, + artifactId, + resolutionId, + NOW + 60, + NOW, + ) +} + +describe('deleteAccountData', () => { + let fixture: ReturnType + + beforeEach(() => { + fixture = createSqliteD1(allMigrations()) + }) + + afterEach(() => fixture.close()) + + it.each([ + ['on', true], + ['off', false], + ])('leaves no row naming the account with foreign key enforcement %s', async (_label, enforced) => { + fixture.raw.exec(`PRAGMA foreign_keys = ${enforced ? 'ON' : 'OFF'}`) + seedAccount(fixture.raw, OCTOCAT) + seedAccount(fixture.raw, HUBOT) + const columns = accountColumns(fixture.raw).filter(column => !(column in KEPT_ACCOUNT_COLUMNS)) + // An unseeded column would pass the check below while deletion misses it. + expect(columns.filter(column => rowsNaming(fixture.raw, column, OCTOCAT.id) === 0)).toEqual([]) + + await deleteAccountData(fixture.db, OCTOCAT.id, NOW) + + expect(columns.filter(column => rowsNaming(fixture.raw, column, OCTOCAT.id) > 0)).toEqual([]) + expect(columns.filter(column => rowsNaming(fixture.raw, column, HUBOT.id) === 0)).toEqual([]) + // Collection entries, App repositories, and check results name no account. + // They must not outlive the rows they belong to. + expect(fixture.raw.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + }) + + it('queues the liked and collected Skills for a counter recount', async () => { + seedAccount(fixture.raw, OCTOCAT) + + await deleteAccountData(fixture.db, OCTOCAT.id, NOW) + + const queued = fixture.raw.prepare( + `SELECT owner, repo, name, reason, queued_at FROM skill_dirty ORDER BY reason`, + ).all() + expect(queued).toEqual([ + { owner: 'acme', repo: 'skills', name: 'tdd', reason: 'curator', queued_at: NOW }, + { owner: 'acme', repo: 'skills', name: 'diagnose', reason: 'like', queued_at: NOW }, + ]) + }) +}) + +describe('revokeGithubGrant', () => { + const stored = { _tag: 'stored', clientId: CLIENT.clientId, accessToken: 'gho_octocat' } as const + + it('deletes the grant with the OAuth app credentials', async () => { + const fetcher = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => new Response(null, { status: 204 })) + + const result = await revokeGithubGrant(stored, CLIENT, fetcher) + + expect(result).toEqual({ _tag: 'revoked' }) + expect(fetcher).toHaveBeenCalledTimes(1) + const [url, init] = fetcher.mock.calls[0]! + expect(url).toBe('https://api.github.com/applications/skilld-oauth-client/grant') + expect(init?.method).toBe('DELETE') + expect(new Headers(init?.headers).get('authorization')).toBe(`Basic ${btoa('skilld-oauth-client:skilld-oauth-secret')}`) + expect(JSON.parse(String(init?.body))).toEqual({ access_token: 'gho_octocat' }) + }) + + it.each([ + ['GitHub refuses the token', async () => new Response('{"message":"Not Found"}', { status: 404 }), { _tag: 'failed', status: 404 }], + ['GitHub is unreachable', async () => Promise.reject(new TypeError('fetch failed')), { _tag: 'failed', status: null }], + ])('reports a failure when %s', async (_label, respond, expected) => { + await expect(revokeGithubGrant(stored, CLIENT, vi.fn(respond))).resolves.toEqual(expected) + }) + + it.each([ + ['no token is stored', { _tag: 'absent' } as const, CLIENT, 'no-token'], + ['the token cannot be decrypted', { _tag: 'unreadable' } as const, CLIENT, 'unreadable-token'], + ['the client secret is missing', stored, { ...CLIENT, clientSecret: '' }, 'no-client-secret'], + ['another OAuth app issued the token', { ...stored, clientId: 'retired-client' }, CLIENT, 'other-client'], + ])('skips GitHub when %s', async (_label, grant, client, reason) => { + const fetcher = vi.fn() + + const result = await revokeGithubGrant(grant, client, fetcher) + + expect(result).toEqual({ _tag: 'skipped', reason }) + expect(fetcher).not.toHaveBeenCalled() + }) +}) + +describe('loadGithubGrant', () => { + let fixture: ReturnType + + beforeEach(() => { + fixture = createSqliteD1(allMigrations()) + }) + + afterEach(() => fixture.close()) + + it('reads the stored sign-in token', async () => { + seedAccount(fixture.raw, OCTOCAT, await encryptToken('gho_octocat', TOKEN_KEY)) + + await expect(loadGithubGrant(fixture.db, OCTOCAT.id, TOKEN_KEY)).resolves.toEqual({ + _tag: 'stored', + clientId: CLIENT.clientId, + accessToken: 'gho_octocat', + }) + }) + + it('reports a token sealed with another key as unreadable', async () => { + seedAccount(fixture.raw, OCTOCAT, await encryptToken('gho_octocat', OTHER_TOKEN_KEY)) + + await expect(loadGithubGrant(fixture.db, OCTOCAT.id, TOKEN_KEY)).resolves.toEqual({ _tag: 'unreadable' }) + }) + + it('reports no token after sign-out', async () => { + seedAccount(fixture.raw, OCTOCAT) + + await expect(loadGithubGrant(fixture.db, OCTOCAT.id, TOKEN_KEY)).resolves.toEqual({ _tag: 'absent' }) + }) +}) + +describe('accountDeletionConfirmed', () => { + it.each([ + ['octocat', true], + [' OctoCat ', true], + ['octocat2', false], + ['hubot', false], + ['', false], + ])('treats %j as confirmation for octocat: %s', (typed, expected) => { + expect(accountDeletionConfirmed(typed, 'octocat')).toBe(expected) + }) +}) + +describe('account deletion endpoint', () => { + let fixture: ReturnType + let sessionUserId: number | null + let requestBody: unknown + let githubResponse: () => Promise + let githubFetch: ReturnType + const clearUserSession = vi.fn() + + beforeEach(async () => { + vi.resetModules() + fixture = createSqliteD1(allMigrations()) + seedAccount(fixture.raw, OCTOCAT, await encryptToken('gho_octocat', TOKEN_KEY)) + seedAccount(fixture.raw, HUBOT) + sessionUserId = OCTOCAT.id + requestBody = { confirm_login: 'octocat' } + githubResponse = async () => new Response(null, { status: 204 }) + githubFetch = vi.fn((..._args: unknown[]) => githubResponse()) + clearUserSession.mockReset() + clearUserSession.mockImplementation(async () => { + sessionUserId = null + return true + }) + + // Unstubbing after each test also removes the wide event stubs from setup. + vi.stubGlobal('createWideEvent', () => ({ context: {}, setLevel: vi.fn(), emit: vi.fn(() => null) })) + vi.stubGlobal('emitOperationalEvent', emitOperationalEvent) + vi.stubGlobal('defineEventHandler', (handler: EventHandler) => handler) + vi.stubGlobal('readBody', () => Promise.resolve(requestBody)) + // The CLI token helpers read the key from the environment in tests. + vi.stubEnv('NUXT_TOKEN_KEY', TOKEN_KEY) + vi.stubGlobal('getUserSession', () => Promise.resolve( + sessionUserId === null ? {} : { user: { id: sessionUserId, login: 'octocat' } }, + )) + vi.stubGlobal('clearUserSession', clearUserSession) + vi.stubGlobal('fetch', githubFetch) + }) + + afterEach(() => { + fixture.close() + vi.unstubAllGlobals() + vi.unstubAllEnvs() + }) + + function request(headers: Record = {}): H3Event { + return { + method: 'DELETE', + path: '/api/me', + context: { + platform: { + db: fixture.db, + env: { + NUXT_TOKEN_KEY: TOKEN_KEY, + NUXT_OAUTH_GITHUB_CLIENT_ID: CLIENT.clientId, + NUXT_OAUTH_GITHUB_CLIENT_SECRET: CLIENT.clientSecret, + }, + }, + }, + node: { req: { headers }, res: {} }, + } as unknown as H3Event + } + + async function deleteAccount(): Promise { + return (await import('../../layers/identity/server/api/me/index.delete')).default + } + + function accountExists(account: Account): boolean { + return rowsNaming(fixture.raw, 'users.id', account.id) === 1 + } + + it('deletes the account, revokes GitHub access, and signs the browser out', async () => { + const handler = await deleteAccount() + + await expect(handler(request())).resolves.toEqual({ ok: true, github_access_revoked: true }) + + expect(accountExists(OCTOCAT)).toBe(false) + expect(accountExists(HUBOT)).toBe(true) + expect(githubFetch).toHaveBeenCalledTimes(1) + expect(JSON.parse(String((githubFetch.mock.calls[0]![1] as RequestInit).body))).toEqual({ access_token: 'gho_octocat' }) + expect(clearUserSession).toHaveBeenCalledTimes(1) + }) + + it('still deletes the account when GitHub keeps the grant', async () => { + githubResponse = async () => new Response('{"message":"Not Found"}', { status: 404 }) + const handler = await deleteAccount() + + await expect(handler(request())).resolves.toEqual({ ok: true, github_access_revoked: false }) + + expect(accountExists(OCTOCAT)).toBe(false) + expect(clearUserSession).toHaveBeenCalledTimes(1) + }) + + it('keeps the account when the typed login names someone else', async () => { + requestBody = { confirm_login: 'hubot' } + const handler = await deleteAccount() + + await expect(handler(request())).rejects.toMatchObject({ + statusCode: 400, + message: 'Type octocat to delete your account.', + }) + + expect(accountExists(OCTOCAT)).toBe(true) + expect(githubFetch).not.toHaveBeenCalled() + expect(clearUserSession).not.toHaveBeenCalled() + }) + + it('refuses a CLI token without a sign-in cookie', async () => { + sessionUserId = null + const cliToken = await issueSession(request(), OCTOCAT.id, { kind: 'pat', deviceLabel: 'laptop' }) + const bearer = { authorization: `Bearer ${cliToken.accessToken}` } + const readAccount = (await import('../../layers/identity/server/api/me/index.get')).default + const handler = await deleteAccount() + + // The token itself is valid: it reads the account. + await expect(readAccount(request(bearer))).resolves.toMatchObject({ login: 'octocat' }) + await expect(handler(request(bearer))).rejects.toMatchObject({ statusCode: 403 }) + + expect(accountExists(OCTOCAT)).toBe(true) + expect(githubFetch).not.toHaveBeenCalled() + }) +}) From e7ce798dd80c2e512f2a69e9450f9b2d27d1703d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 17 Sep 2026 01:38:21 +1000 Subject: [PATCH 2/5] feat(marketing): add a privacy page linked from the footer skilld.dev had no page that says what it stores, why, and for how long. The page describes the site after #240 and #241 and explains how to delete an account. --- app/app.vue | 6 ++ layers/marketing/app/pages/privacy.vue | 11 ++ layers/marketing/content/pages/privacy.md | 126 ++++++++++++++++++++++ layers/marketing/nuxt.config.ts | 2 +- 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 layers/marketing/app/pages/privacy.vue create mode 100644 layers/marketing/content/pages/privacy.md diff --git a/app/app.vue b/app/app.vue index e5768778..4b1d7a88 100644 --- a/app/app.vue +++ b/app/app.vue @@ -319,6 +319,12 @@ watch(() => route.fullPath, () => { > Accessibility + + Privacy + +const { data } = await useMarketingArticle({ + collection: 'pages', + path: '/privacy', + canonicalPath: '/privacy', +}) + + + diff --git a/layers/marketing/content/pages/privacy.md b/layers/marketing/content/pages/privacy.md new file mode 100644 index 00000000..7dd37531 --- /dev/null +++ b/layers/marketing/content/pages/privacy.md @@ -0,0 +1,126 @@ +--- +title: Privacy +description: What skilld.dev stores, why it needs the data, how long it keeps it, and how you delete your account. +label: Last updated 17 September 2026 +updatedAt: 2026-09-17 +--- + +## In short + +- You can browse skilld.dev and run Skills without an account. +- skilld.dev loads no analytics or advertising scripts. +- An account is optional. You sign in with [GitHub](https://github.com) to like Skills, watch Repositories, get email, or connect the skilld CLI. +- You can delete your account from your dashboard at any time. +- The skilld CLI sends no telemetry. + +## When you visit + +[Cloudflare](https://cloudflare.com) hosts skilld.dev. Every request goes through Cloudflare, which uses your IP address to deliver the page. + +skilld writes request logs to Cloudflare Workers Logs. A log entry holds the route, the response status, and the time the request took. It also holds your country and the Cloudflare data center that answered. It holds no IP address, user agent, or query string. + +### Cookies + +skilld.dev sets these cookies and no others: + +- `nuxt-session` keeps you signed in. Before you sign in, it holds only a random ID. After you sign in, it holds your account ID, login, name, and avatar URL, encrypted. It ends when you close your browser. +- `__nkpv` records which version of the site your browser loaded, so an update does not break an open page. It lasts 7 days. +- `nuxt-auth-state` protects the GitHub sign-in step against forged requests. It lasts 10 minutes. +- `cli_return_to` returns you to the CLI sign-in page after you sign in with GitHub. It lasts 10 minutes. + +### Your browser + +Your browser keeps your color mode and your 5 most recent searches in local storage. If you close the email hint on a Skill page, session storage remembers that until you close the tab. This data stays on your device. + +### Search + +Search sends your query to Workers AI on Cloudflare to find matching Skills. skilld stores no copy of the query text. It caches the query's embedding, a list of numbers, for up to one day. The cache entry holds nothing that identifies you. + +### Skill pages + +A Skill page never makes your browser load an image from the Skill author's own server. + +## When you sign in with GitHub + +Sign-in uses GitHub OAuth. skilld asks for two scopes: + +- `read:user` reads your profile: your GitHub ID, login, name, and avatar. +- `user:email` reads your verified primary email address when your GitHub profile hides your email. + +skilld uses the same token for two more jobs. On your first sign-in, it searches your public Repositories for `SKILL.md` files. It adds the Skills it finds to the registry. When you import your stars, it reads the list of Repositories you starred. + +skilld stores this data for your account: + +- Your GitHub ID, login, name, avatar URL, and email address. +- Your GitHub tokens, encrypted. Signing out deletes them. +- The Skills you like. Your likes are public at `skilld.dev/@your-login/liked`. +- The Repositories you watch and the stars you import. +- The collections you create. Collections are public. +- Your email settings and the email address you save. +- When you created your account and when you last signed in. + +## Email + +skilld sends email only after you turn it on, during sign-up or in your dashboard. + +- **Weekly email:** new trending Skills every Monday. +- **Monthly digest:** changes to your liked Skills and watched Repositories, on the first day of each month. + +skilld sends to the address you save. If you save no address, it uses the email address from your GitHub account. Cloudflare delivers each email from `noreply@mail.skilld.dev`. Every email has an unsubscribe link. skilld records no clicks by individual readers on email links. + +## The skilld CLI + +The skilld CLI sends no telemetry or analytics. It calls the skilld.dev API only when a command needs it, such as `skilld search`, `skilld run`, or `skilld install`. The [CLI README](https://github.com/skilld-dev/skilld#privacy) lists every network request the CLI makes. + +When you sign in with the CLI, skilld stores a record for that token: + +- The token, hashed or encrypted. +- The CLI version. +- The token's label, such as the name you give a personal token. +- When skilld issued the token, and when a request last used it. + +If the client sends your operating system and processor type, such as `darwin-arm64`, device sign-in records it. The sign-in page shows it, so you can check which machine asks for access. You can revoke a device in your [dashboard](/me/devices). + +## Error reports + +skilld uses [Sentry](https://sentry.io) to find and fix errors. + +- Error reports from your browser go to skilld.dev first, and skilld.dev forwards them. Sentry does not receive your IP address. +- Reports keep the page path and your browser's user agent. They remove cookies, query strings, request bodies, and other request headers. +- Your browser sends error reports only. It sends no session replays and no performance traces. +- The skilld server sends Sentry timing data for 5% of requests, with the same data removed. +- Sentry stores this data in its United States region. + +## Services that handle your data + +- Cloudflare hosts skilld.dev and runs its database, cache, email delivery, Workers AI search, and request logs. It handles requests, account data, email addresses, and search queries. +- GitHub handles sign-in and holds the source of every Skill. It handles your GitHub profile and tokens. +- Sentry receives error reports, with the data listed above removed. + +## How long skilld keeps data + +- Your account data stays until you delete your account. +- Signing out deletes your stored GitHub tokens. +- CLI tokens stay for 7 days after they expire or you revoke them. +- CLI sign-in codes and device sign-in requests stay for 1 day. +- Email send records and unsubscribe history stay for 90 days. + +A daily job deletes these records at 04:30 UTC. Two kinds of digest records stay longer. The newest digest record for each account stays, because the next digest starts from it. A digest that did not send successfully also stays, so a retry cannot send it twice. + +## Delete your account + +Open your [dashboard](/me) and select the Delete account button. Type your GitHub login to confirm. skilld then deletes all of this at once: + +- Your GitHub profile details, email address, and stored tokens. +- Your likes, watched Repositories, and imported stars. +- Your collections. +- Your email settings and email history. +- Your CLI tokens and device sign-ins. + +After that, skilld asks GitHub to revoke its access to your GitHub account. If GitHub does not confirm, your dashboard tells you. You can then revoke skilld in your [GitHub settings](https://github.com/settings/applications). + +Skills in your public Repositories stay in the registry, because GitHub is their source. + +## Contact + +Email [harlan@harlanzw.com](mailto:harlan@harlanzw.com) with questions about your data. To report a security problem, use [GitHub private vulnerability reporting](https://github.com/skilld-dev/skilld/security/advisories/new). diff --git a/layers/marketing/nuxt.config.ts b/layers/marketing/nuxt.config.ts index 7584bb5f..ba5a5959 100644 --- a/layers/marketing/nuxt.config.ts +++ b/layers/marketing/nuxt.config.ts @@ -1,6 +1,6 @@ // Marketing layer: SEO content. Owns /skills (marketing index), /skills/guide, // /skills/trending, /skills/official, /skills/stats, /frameworks/*, -// /accessibility. +// /accessibility, /privacy. // See docs/adr/0001-url-pillars-and-layers.md export default defineNuxtConfig({ modules: ['@harlan-zw/comark-content'], From d3d4a1ed111a176f30415a66b0237258b4b0b14a Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 17 Sep 2026 01:49:15 +1000 Subject: [PATCH 3/5] docs(marketing): describe the full privacy stack on /privacy The page described the site after #240 and #241 only. #242, #243, #244, and #246 change CLI token labels, email click counts, like visibility, repository indexing, and image loading, and D1 recovery keeps deleted rows for up to 30 days. --- layers/marketing/content/pages/privacy.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/layers/marketing/content/pages/privacy.md b/layers/marketing/content/pages/privacy.md index 7dd37531..f1b4f8ce 100644 --- a/layers/marketing/content/pages/privacy.md +++ b/layers/marketing/content/pages/privacy.md @@ -36,9 +36,9 @@ Your browser keeps your color mode and your 5 most recent searches in local stor Search sends your query to Workers AI on Cloudflare to find matching Skills. skilld stores no copy of the query text. It caches the query's embedding, a list of numbers, for up to one day. The cache entry holds nothing that identifies you. -### Skill pages +### Images -A Skill page never makes your browser load an image from the Skill author's own server. +Every avatar and every image in a Skill loads through skilld.dev. skilld.dev fetches the image from GitHub, X, Bluesky, or the host the Skill names, and sends none of your request details. Those hosts never see your IP address, and your browser never contacts them for an image. skilld.dev also sends a referrer policy that shares only the site origin with other sites you open. ## When you sign in with GitHub @@ -47,13 +47,16 @@ Sign-in uses GitHub OAuth. skilld asks for two scopes: - `read:user` reads your profile: your GitHub ID, login, name, and avatar. - `user:email` reads your verified primary email address when your GitHub profile hides your email. -skilld uses the same token for two more jobs. On your first sign-in, it searches your public Repositories for `SKILL.md` files. It adds the Skills it finds to the registry. When you import your stars, it reads the list of Repositories you starred. +skilld uses the same token for two more jobs, and only when you ask: + +- If you choose to add your Skills during sign-up, or later from your profile, skilld searches your public Repositories for `SKILL.md` files. It adds the Skills it finds to the registry. +- When you import your stars, skilld reads the list of Repositories you starred. skilld stores this data for your account: - Your GitHub ID, login, name, avatar URL, and email address. - Your GitHub tokens, encrypted. Signing out deletes them. -- The Skills you like. Your likes are public at `skilld.dev/@your-login/liked`. +- The Skills you like. Only you can see them, unless you turn on "Show liked Skills on your profile" in your dashboard. - The Repositories you watch and the stars you import. - The collections you create. Collections are public. - Your email settings and the email address you save. @@ -66,7 +69,9 @@ skilld sends email only after you turn it on, during sign-up or in your dashboar - **Weekly email:** new trending Skills every Monday. - **Monthly digest:** changes to your liked Skills and watched Repositories, on the first day of each month. -skilld sends to the address you save. If you save no address, it uses the email address from your GitHub account. Cloudflare delivers each email from `noreply@mail.skilld.dev`. Every email has an unsubscribe link. skilld records no clicks by individual readers on email links. +skilld sends to the address you save. If you save no address, it uses the email address from your GitHub account. Cloudflare delivers each email from `noreply@mail.skilld.dev`. Every email has an unsubscribe link. + +Links in these emails go through skilld.dev. skilld counts clicks per day, per email issue, and per link. Every reader of an issue gets the same links, so a count holds no name, email address, IP address, or user agent. ## The skilld CLI @@ -76,7 +81,7 @@ When you sign in with the CLI, skilld stores a record for that token: - The token, hashed or encrypted. - The CLI version. -- The token's label, such as the name you give a personal token. +- A label for the token. The skilld CLI sends your computer's hostname, so you can tell your devices apart. Only you can see it. - When skilld issued the token, and when a request last used it. If the client sends your operating system and processor type, such as `darwin-arm64`, device sign-in records it. The sign-in page shows it, so you can check which machine asks for access. You can revoke a device in your [dashboard](/me/devices). @@ -93,7 +98,7 @@ skilld uses [Sentry](https://sentry.io) to find and fix errors. ## Services that handle your data -- Cloudflare hosts skilld.dev and runs its database, cache, email delivery, Workers AI search, and request logs. It handles requests, account data, email addresses, and search queries. +- Cloudflare hosts skilld.dev and runs its database, cache, email delivery, image proxy, Workers AI search, and request logs. It handles requests, account data, email addresses, and search queries. - GitHub handles sign-in and holds the source of every Skill. It handles your GitHub profile and tokens. - Sentry receives error reports, with the data listed above removed. @@ -107,6 +112,8 @@ skilld uses [Sentry](https://sentry.io) to find and fix errors. A daily job deletes these records at 04:30 UTC. Two kinds of digest records stay longer. The newest digest record for each account stays, because the next digest starts from it. A digest that did not send successfully also stays, so a retry cannot send it twice. +Cloudflare D1 keeps a point-in-time recovery history of the database. A deleted row can stay in that history for up to 30 days, and then it expires. + ## Delete your account Open your [dashboard](/me) and select the Delete account button. Type your GitHub login to confirm. skilld then deletes all of this at once: @@ -119,7 +126,7 @@ Open your [dashboard](/me) and select the Delete account button. Type your GitHu After that, skilld asks GitHub to revoke its access to your GitHub account. If GitHub does not confirm, your dashboard tells you. You can then revoke skilld in your [GitHub settings](https://github.com/settings/applications). -Skills in your public Repositories stay in the registry, because GitHub is their source. +Skills in your public Repositories stay in the registry, because GitHub is their source. Deleted data can stay in the database recovery history for up to 30 days. ## Contact From 3817b64a614d26b93a620871e0ebf0e15cfbd6f0 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 17 Sep 2026 02:05:17 +1000 Subject: [PATCH 4/5] docs(marketing): describe Cloudflare Web Analytics on /privacy The owner keeps Web Analytics on, so the page no longer claims skilld.dev runs no analytics. It lists what Cloudflare documents the beacon records. --- layers/marketing/content/pages/privacy.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/layers/marketing/content/pages/privacy.md b/layers/marketing/content/pages/privacy.md index f1b4f8ce..f6c86568 100644 --- a/layers/marketing/content/pages/privacy.md +++ b/layers/marketing/content/pages/privacy.md @@ -8,7 +8,7 @@ updatedAt: 2026-09-17 ## In short - You can browse skilld.dev and run Skills without an account. -- skilld.dev loads no analytics or advertising scripts. +- skilld.dev uses Cloudflare Web Analytics, which sets no cookies. It loads no advertising scripts. - An account is optional. You sign in with [GitHub](https://github.com) to like Skills, watch Repositories, get email, or connect the skilld CLI. - You can delete your account from your dashboard at any time. - The skilld CLI sends no telemetry. @@ -19,6 +19,17 @@ updatedAt: 2026-09-17 skilld writes request logs to Cloudflare Workers Logs. A log entry holds the route, the response status, and the time the request took. It also holds your country and the Cloudflare data center that answered. It holds no IP address, user agent, or query string. +### Web Analytics + +skilld.dev uses [Cloudflare Web Analytics](https://www.cloudflare.com/web-analytics/) to count page views and measure page speed. According to [Cloudflare's metrics documentation](https://developers.cloudflare.com/web-analytics/data-metrics/), it records: + +- Page views and visits. +- Page load timings and Core Web Vitals. +- The page path and the site that referred you. +- Your country, device type, browser, and operating system. + +Cloudflare states that Web Analytics uses no cookies or local storage, and that it does not fingerprint visitors by IP address or user agent. The script loads from skilld.dev itself. Its reports also go to skilld.dev, which forwards only the report body to Cloudflare. Cloudflare's analytics service does not receive your IP address, cookies, or other request headers from that request. + ### Cookies skilld.dev sets these cookies and no others: @@ -98,7 +109,7 @@ skilld uses [Sentry](https://sentry.io) to find and fix errors. ## Services that handle your data -- Cloudflare hosts skilld.dev and runs its database, cache, email delivery, image proxy, Workers AI search, and request logs. It handles requests, account data, email addresses, and search queries. +- Cloudflare hosts skilld.dev and runs its database, cache, email delivery, image proxy, Web Analytics, Workers AI search, and request logs. It handles requests, account data, email addresses, analytics reports, and search queries. - GitHub handles sign-in and holds the source of every Skill. It handles your GitHub profile and tokens. - Sentry receives error reports, with the data listed above removed. From 4c4d860c78a6fe1981ffc1a469c24ce2824c3ca6 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 22 Sep 2026 12:32:15 +1000 Subject: [PATCH 5/5] docs(marketing): say what the usage counts hold /privacy described a site with no usage analytics. skilld.dev keeps anonymous counts of copied commands, because it needs to know which Skills people run. The page now says what a count holds, where it lives, and how long it stays, plus the 1% timing traces, the counted GitHub links in email, and the new defaults for liked Skills and Repository indexing. --- layers/marketing/content/pages/privacy.md | 32 ++++++++++++++++++----- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/layers/marketing/content/pages/privacy.md b/layers/marketing/content/pages/privacy.md index f6c86568..19f348f3 100644 --- a/layers/marketing/content/pages/privacy.md +++ b/layers/marketing/content/pages/privacy.md @@ -1,14 +1,14 @@ --- title: Privacy description: What skilld.dev stores, why it needs the data, how long it keeps it, and how you delete your account. -label: Last updated 17 September 2026 -updatedAt: 2026-09-17 +label: Last updated 22 September 2026 +updatedAt: 2026-09-22 --- ## In short - You can browse skilld.dev and run Skills without an account. -- skilld.dev uses Cloudflare Web Analytics, which sets no cookies. It loads no advertising scripts. +- skilld.dev counts page views and command copies. The counts hold no name, IP address, or cookie. It loads no advertising scripts. - An account is optional. You sign in with [GitHub](https://github.com) to like Skills, watch Repositories, get email, or connect the skilld CLI. - You can delete your account from your dashboard at any time. - The skilld CLI sends no telemetry. @@ -19,6 +19,8 @@ updatedAt: 2026-09-17 skilld writes request logs to Cloudflare Workers Logs. A log entry holds the route, the response status, and the time the request took. It also holds your country and the Cloudflare data center that answered. It holds no IP address, user agent, or query string. +Cloudflare also records a timing trace for 1 request in 100. A trace holds the route and how long each step took. It holds no IP address and no request body. + ### Web Analytics skilld.dev uses [Cloudflare Web Analytics](https://www.cloudflare.com/web-analytics/) to count page views and measure page speed. According to [Cloudflare's metrics documentation](https://developers.cloudflare.com/web-analytics/data-metrics/), it records: @@ -30,6 +32,21 @@ skilld.dev uses [Cloudflare Web Analytics](https://www.cloudflare.com/web-analyt Cloudflare states that Web Analytics uses no cookies or local storage, and that it does not fingerprint visitors by IP address or user agent. The script loads from skilld.dev itself. Its reports also go to skilld.dev, which forwards only the report body to Cloudflare. Cloudflare's analytics service does not receive your IP address, cookies, or other request headers from that request. +### Usage counts + +skilld.dev is early, so it needs to know which Skills people run and where they find them. + +When you copy a printed command, your browser tells skilld.dev. skilld stores one count with: + +- The surface that printed the command, such as a Skill card or the home page. +- Whether the command runs a Skill or installs it. +- The Skill, Repository, or collection the command names. +- Your country. + +That count goes to [Cloudflare Analytics Engine](https://developers.cloudflare.com/analytics/analytics-engine/), which keeps it for 90 days. It holds no IP address, no cookie, and no account ID, so a count cannot be traced to you or joined to your account. skilld keeps no record of an individual copy. + +The skilld CLI sends no telemetry. skilld.dev accepts an anonymous run count from a CLI at the same address, under the same rules, and drops any account ID the request carries. + ### Cookies skilld.dev sets these cookies and no others: @@ -60,14 +77,14 @@ Sign-in uses GitHub OAuth. skilld asks for two scopes: skilld uses the same token for two more jobs, and only when you ask: -- If you choose to add your Skills during sign-up, or later from your profile, skilld searches your public Repositories for `SKILL.md` files. It adds the Skills it finds to the registry. +- When you sign in, skilld offers to search your public Repositories for `SKILL.md` files and add the Skills it finds to the registry. You can decline, and you can run the same search later from your profile. - When you import your stars, skilld reads the list of Repositories you starred. skilld stores this data for your account: - Your GitHub ID, login, name, avatar URL, and email address. - Your GitHub tokens, encrypted. Signing out deletes them. -- The Skills you like. Only you can see them, unless you turn on "Show liked Skills on your profile" in your dashboard. +- The Skills you like. Your profile shows them, unless you turn off "Show liked Skills on your profile" in your dashboard. - The Repositories you watch and the stars you import. - The collections you create. Collections are public. - Your email settings and the email address you save. @@ -82,7 +99,7 @@ skilld sends email only after you turn it on, during sign-up or in your dashboar skilld sends to the address you save. If you save no address, it uses the email address from your GitHub account. Cloudflare delivers each email from `noreply@mail.skilld.dev`. Every email has an unsubscribe link. -Links in these emails go through skilld.dev. skilld counts clicks per day, per email issue, and per link. Every reader of an issue gets the same links, so a count holds no name, email address, IP address, or user agent. +Links in these emails go through skilld.dev. skilld counts clicks per day, per email issue, and per link. This includes the links to GitHub, which are counted by Repository. Every reader of an issue gets the same links, so a count holds no name, email address, IP address, or user agent. ## The skilld CLI @@ -109,7 +126,7 @@ skilld uses [Sentry](https://sentry.io) to find and fix errors. ## Services that handle your data -- Cloudflare hosts skilld.dev and runs its database, cache, email delivery, image proxy, Web Analytics, Workers AI search, and request logs. It handles requests, account data, email addresses, analytics reports, and search queries. +- Cloudflare hosts skilld.dev and runs its database, cache, email delivery, image proxy, Web Analytics, usage counts, Workers AI search, and request logs. It handles requests, account data, email addresses, analytics reports, and search queries. - GitHub handles sign-in and holds the source of every Skill. It handles your GitHub profile and tokens. - Sentry receives error reports, with the data listed above removed. @@ -120,6 +137,7 @@ skilld uses [Sentry](https://sentry.io) to find and fix errors. - CLI tokens stay for 7 days after they expire or you revoke them. - CLI sign-in codes and device sign-in requests stay for 1 day. - Email send records and unsubscribe history stay for 90 days. +- Usage counts and request logs stay for 90 days. Email click counts are kept as daily totals with no account in them. A daily job deletes these records at 04:30 UTC. Two kinds of digest records stay longer. The newest digest record for each account stays, because the next digest starts from it. A digest that did not send successfully also stays, so a retry cannot send it twice.