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 + import type { + IdentityAccountDeleteBody, + IdentityAccountDeleteResponse, IdentityEmailPatchBody, IdentityMutationResponse, IdentitySubscriptionRef, } from '../../../shared/contracts/account' import type { StarsSyncResponse } from '../../utils/sync-starred-repos' import { avatarProxyUrl } from '#shared/image-proxy' +import { accountDeletionConfirmed } from '../../../shared/contracts/account' import { identityAccountQueries, identityAccountQueryOptions } from '../../queries/account' import { syncStarredRepos } from '../../utils/sync-starred-repos' @@ -161,6 +164,58 @@ function fmtDate(ts: number | null | undefined): string { timeZone: me.value?.timezone ?? 'UTC', }).format(new Date(ts * 1000)) } + +const { clear: clearSession } = useUserSession() +const deleteDialogOpen = ref(false) +const deleteConfirmation = ref('') +const deleteFailed = ref(false) +const deleteConfirmed = computed(() => !!me.value && accountDeletionConfirmed(deleteConfirmation.value, me.value.login)) +// The RPC client sends no body with DELETE, and this request needs the typed login. +const deleteAccountMutation = useNuxtMutation({ + mutation: body => $fetch('/api/me', { method: 'DELETE', body }), +}) + +watch(deleteDialogOpen, (open) => { + if (!open) { + deleteConfirmation.value = '' + deleteFailed.value = false + } +}) + +async function deleteAccount() { + if (!deleteConfirmed.value || deleteAccountMutation.pending.value) + return + deleteFailed.value = false + const deleted = await deleteAccountMutation.mutateSafe({ confirm_login: deleteConfirmation.value }) + if (deleted._tag === 'err') { + deleteFailed.value = true + return + } + + await clearSession() + toast.add(deleted.data.github_access_revoked + ? { + title: 'Your account is deleted', + description: 'skilld no longer has access to your GitHub account.', + color: 'success', + icon: 'i-lucide-check-circle', + } + : { + title: 'Your account is deleted', + description: 'GitHub did not confirm that skilld lost access. Revoke skilld in your GitHub settings.', + color: 'warning', + icon: 'i-lucide-triangle-alert', + duration: 20_000, + actions: [{ + label: 'Open GitHub settings', + to: 'https://github.com/settings/applications', + target: '_blank', + color: 'neutral', + variant: 'outline', + }], + }) + await navigateTo('/') +} 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/layers/marketing/app/pages/privacy.vue b/layers/marketing/app/pages/privacy.vue new file mode 100644 index 00000000..832e3ec6 --- /dev/null +++ b/layers/marketing/app/pages/privacy.vue @@ -0,0 +1,11 @@ + + + diff --git a/layers/marketing/content/pages/privacy.md b/layers/marketing/content/pages/privacy.md new file mode 100644 index 00000000..19f348f3 --- /dev/null +++ b/layers/marketing/content/pages/privacy.md @@ -0,0 +1,162 @@ +--- +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 22 September 2026 +updatedAt: 2026-09-22 +--- + +## In short + +- You can browse skilld.dev and run Skills without an account. +- 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. + +## 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. + +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: + +- 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. + +### 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: + +- `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. + +### Images + +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 + +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, and only when you ask: + +- 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. 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. +- 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. + +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 + +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. +- 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). + +## 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, 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. + +## 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. +- 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. + +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: + +- 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. Deleted data can stay in the database recovery history for up to 30 days. + +## 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'], 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() + }) +})