diff --git a/app/pages/@[login]/index.vue b/app/pages/@[login]/index.vue index 55beab66..099eed8a 100644 --- a/app/pages/@[login]/index.vue +++ b/app/pages/@[login]/index.vue @@ -61,6 +61,13 @@ const { data: profile } = await useFetch( { key: () => `community-profile-${login.value}` }, ) +// 404 when the list is private and the viewer is not its owner, so the link +// stays hidden. That 404 is the expected answer; the page needs no error UI. +const { data: likedList } = await useFetch<{ access: 'public' | 'owner' }>( + () => `/api/likes/by-user/${login.value}/visibility`, + { key: () => `liked-visibility-${login.value}` }, +) + const displayName = computed(() => profile.value?.displayName || `@${login.value}`) const profileDescription = computed(() => profile.value?.description ?? '') const avatar = computed(() => profile.value?.avatar || `https://github.com/${login.value}.png?size=192`) @@ -253,6 +260,16 @@ defineOgImage('Curator.takumi', { :loading="scanning" @click="scanRepos" /> + +

+

+

+

+
diff --git a/layers/identity/app/components/OwnedSkillsPrompt.vue b/layers/identity/app/components/OwnedSkillsPrompt.vue new file mode 100644 index 00000000..1adee645 --- /dev/null +++ b/layers/identity/app/components/OwnedSkillsPrompt.vue @@ -0,0 +1,134 @@ + + + diff --git a/layers/identity/app/pages/me/index.vue b/layers/identity/app/pages/me/index.vue index 2bdbe497..244d12de 100644 --- a/layers/identity/app/pages/me/index.vue +++ b/layers/identity/app/pages/me/index.vue @@ -2,6 +2,7 @@ import type { IdentityEmailPatchBody, IdentityMutationResponse, + IdentityPrivacyPatchBody, IdentitySubscriptionRef, } from '../../../shared/contracts/account' import type { StarsSyncResponse } from '../../utils/sync-starred-repos' @@ -59,6 +60,21 @@ const saveEmailMutation = useNuxtMutation({ + mutation: async (body) => { + const result = await rpc.execute(identityAccountQueries.savePrivacy(), body) + await refreshAccount() + return result + }, + onError: actionFailed('save your privacy settings'), +}) +async function setLikesPublic(likesPublic: boolean) { + await savePrivacyMutation.mutateSafe({ likes_public: likesPublic }) +} +async function setRepoIndexing(repoIndexing: boolean) { + await savePrivacyMutation.mutateSafe({ repo_indexing: repoIndexing }) +} + const removeLikeMutation = useNuxtMutation({ mutation: async (skill) => { const path = `/api/me/likes/${skill.owner}/${skill.repo}/${skill.name}` @@ -392,6 +408,44 @@ function fmtDate(ts: number | null | undefined): string {
+
+

+ Privacy +

+
+ + +
+
+ +
+
+

Repository coverage diff --git a/layers/identity/app/pages/onboarding/discover.vue b/layers/identity/app/pages/onboarding/discover.vue index b66e8577..24f6cab6 100644 --- a/layers/identity/app/pages/onboarding/discover.vue +++ b/layers/identity/app/pages/onboarding/discover.vue @@ -4,6 +4,8 @@ import { syncStarredRepos } from '../../utils/sync-starred-repos' definePageMeta({ middleware: ['auth'] }) +const { user } = useUserSession() + interface Skill { name: string displayName: string @@ -111,6 +113,11 @@ useSeoMeta({ title: 'Discover skills', robots: 'noindex' })

We check starred GitHub repos with "skill" in the name against the registry. Choose the ones to watch. You'll only get a digest when one changes.

+
defineNuxtRpcMutation({ + body: identityPrivacyPatchBodySchema, + method: 'PATCH', + path: '/api/me/privacy', + response: identityMutationResponseSchema, + }), + scanOwnedRepos: () => defineNuxtRpcMutation({ + body: null, + method: 'POST', + path: '/api/me/repos/scan', + response: identityOwnedRepoScanResponseSchema, + }), finishOnboarding: () => defineNuxtRpcMutation({ body: null, method: 'POST', diff --git a/layers/identity/app/utils/owned-repo-scan.ts b/layers/identity/app/utils/owned-repo-scan.ts new file mode 100644 index 00000000..15f9671c --- /dev/null +++ b/layers/identity/app/utils/owned-repo-scan.ts @@ -0,0 +1,64 @@ +import type { IdentityOwnedRepoScanResponse } from '../../shared/contracts/account' + +export type OwnedRepoScanNotice + = | { _tag: 'added', message: string } + | { _tag: 'none_found', message: string } + | { _tag: 'retry', message: string } + | { _tag: 'sign_in', message: string } + +function repositories(count: number): string { + return `${count} ${count === 1 ? 'repository' : 'repositories'}` +} + +function addedLine(count: number): string { + return count > 0 ? `Added Skills from ${repositories(count)}. ` : '' +} + +/** + * Turn one scan result into the line the onboarding prompt shows. + * + * A `partial` scan still reports what it added, then asks for a retry so the + * rest of the results can arrive. + */ +export function ownedRepoScanNotice(result: IdentityOwnedRepoScanResponse): OwnedRepoScanNotice { + switch (result._tag) { + case 'auth_failure': + return { _tag: 'sign_in', message: 'GitHub did not accept your sign-in. Sign in with GitHub again.' } + case 'rate_limited': + return { _tag: 'retry', message: 'GitHub limited the check. Try again in a few minutes.' } + case 'provider_failure': + return { _tag: 'retry', message: 'GitHub did not answer. Try again later.' } + case 'partial': + return { + _tag: 'retry', + message: `${addedLine(result.reposSynced)}GitHub did not return every result. Try again later.`, + } + case 'complete': + if (result.reposFound === 0) + return { _tag: 'none_found', message: 'No SKILL.md files found in your public repositories.' } + if (result.reposFailed > 0) { + return { + _tag: 'retry', + message: `${addedLine(result.reposSynced)}${repositories(result.reposFailed)} failed. Try again later.`, + } + } + if (result.reposSynced === 0) + return { _tag: 'none_found', message: `Checked ${repositories(result.reposFound)}. No new Skills to add.` } + return { _tag: 'added', message: `Added Skills from ${repositories(result.reposSynced)}.` } + } +} + +/** + * Turn a failed scan request into the line the prompt shows. + * + * A 401 means skilld has no usable GitHub token for the account, so the only + * fix is a new GitHub sign-in. + */ +export function ownedRepoScanFailureNotice(error: unknown): OwnedRepoScanNotice { + const status = typeof error === 'object' && error !== null + ? (error as { statusCode?: unknown, status?: unknown }).statusCode ?? (error as { status?: unknown }).status + : undefined + if (status === 401) + return { _tag: 'sign_in', message: 'Your GitHub sign-in expired. Sign in with GitHub again.' } + return { _tag: 'retry', message: 'Could not check your repositories. Try again.' } +} diff --git a/layers/identity/server/api/likes/by-user/[login].get.ts b/layers/identity/server/api/likes/by-user/[login].get.ts index 31d77c3d..52483424 100644 --- a/layers/identity/server/api/likes/by-user/[login].get.ts +++ b/layers/identity/server/api/likes/by-user/[login].get.ts @@ -1,5 +1,6 @@ import { defineApiHandler } from '#shared/server/handler' import { canonicalRepoSkillPath } from '#shared/skill-routes' +import { lookupLikedList } from '../../../utils/liked-list-access' interface PublicLikeRow { owner: string @@ -14,22 +15,26 @@ interface PublicLikeRow { } /** - * Public list behind /@/liked. Only skills still present in the registry - * are returned — an INNER JOIN, unlike the owner's own list, because a visitor + * The list behind /@/liked. Only Skills still present in the registry + * are returned: an INNER JOIN, unlike the owner's own list, because a visitor * has no use for a row that no longer resolves to a page. + * + * The list is private by default. It returns 404 unless its owner turned it + * on or the viewer is the owner. */ export default defineApiHandler({ - handler: async ({ event, platform }) => { + handler: async ({ event, platform, user: viewer }) => { const login = getRouterParam(event, 'login') if (!login) throw createError({ statusCode: 400, message: 'Missing login' }) - const user = await platform.db.prepare( - `SELECT id, login, name, avatar FROM users WHERE login = ?1 COLLATE NOCASE`, - ).bind(login).first<{ id: number, login: string, name: string | null, avatar: string | null }>() + // The answer depends on the viewer, so no shared cache may keep it. + setHeader(event, 'cache-control', 'private, no-store') - if (!user) + const lookup = await lookupLikedList(platform.db, login, viewer?.id ?? null) + if (lookup._tag === 'not_found') throw createError({ statusCode: 404, message: 'Not found' }) + const { owner: user, access } = lookup const { results } = await platform.db.prepare( `SELECT @@ -54,6 +59,7 @@ export default defineApiHandler({ ).bind(user.id).all() return { + access, author: { login: user.login, name: user.name, avatar: user.avatar }, items: (results ?? []).map(skill => ({ ...skill, diff --git a/layers/identity/server/api/likes/by-user/[login]/visibility.get.ts b/layers/identity/server/api/likes/by-user/[login]/visibility.get.ts new file mode 100644 index 00000000..fd9e4800 --- /dev/null +++ b/layers/identity/server/api/likes/by-user/[login]/visibility.get.ts @@ -0,0 +1,23 @@ +import { defineApiHandler } from '#shared/server/handler' +import { lookupLikedList } from '../../../../utils/liked-list-access' + +/** + * Lets a profile page decide whether to link to /@login/liked without loading + * the list. A private list returns 404 to everyone except its owner. + */ +export default defineApiHandler({ + handler: async ({ event, platform, user }) => { + const login = getRouterParam(event, 'login') + if (!login) + throw createError({ statusCode: 400, message: 'Missing login' }) + + // The answer depends on the viewer, so no shared cache may keep it. + setHeader(event, 'cache-control', 'private, no-store') + + const lookup = await lookupLikedList(platform.db, login, user?.id ?? null) + if (lookup._tag === 'not_found') + throw createError({ statusCode: 404, message: 'Not found' }) + + return { access: lookup.access } + }, +}) diff --git a/layers/identity/server/api/me/privacy.patch.ts b/layers/identity/server/api/me/privacy.patch.ts new file mode 100644 index 00000000..cc7a8ee9 --- /dev/null +++ b/layers/identity/server/api/me/privacy.patch.ts @@ -0,0 +1,34 @@ +import { defineApiHandler } from '#shared/server/handler' +import { identityMutationResponseSchema, identityPrivacyPatchBodySchema } from '../../../shared/contracts/account' +import { authenticated } from '../../policies/authenticated' +import { requireUserRow } from '../../utils/users' + +/** + * Saves the account's privacy switches. + * + * Both are on by default. A request names only the switches it changes, so + * turning one off cannot silently reset the other. + */ +export default defineApiHandler({ + schema: identityPrivacyPatchBodySchema, + policy: [authenticated], + response: identityMutationResponseSchema, + handler: async ({ event, body, platform }) => { + const u = await requireUserRow(event) + const updates: string[] = [] + const values: (number | string)[] = [] + if (body.likes_public !== undefined) { + updates.push(`likes_public = ?${updates.length + 1}`) + values.push(body.likes_public ? 1 : 0) + } + if (body.repo_indexing !== undefined) { + updates.push(`repo_indexing = ?${updates.length + 1}`) + values.push(body.repo_indexing ? 1 : 0) + } + + await platform.db.prepare( + `UPDATE users SET ${updates.join(', ')} WHERE id = ?${updates.length + 1}`, + ).bind(...values, u.id).run() + return { ok: true as const } + }, +}) diff --git a/layers/identity/server/api/me/repos/scan.post.ts b/layers/identity/server/api/me/repos/scan.post.ts index 507f8063..47ffdbb5 100644 --- a/layers/identity/server/api/me/repos/scan.post.ts +++ b/layers/identity/server/api/me/repos/scan.post.ts @@ -4,11 +4,20 @@ import { ReposScanBody } from '../../../schemas/repos' import { ownedRepoScanResponse, scanOwnedRepos } from '../../../utils/scan-owned-repos' import { requireGithubUserToken, requireUserRow } from '../../../utils/users' +/** + * Scans the account's public repositories for Skills and indexes them. + * + * Indexing is on by default and sign-in runs the same scan. An account that + * turned it off in /me gets no scan from here either, so the switch is the one + * place that decides. + */ export default defineApiHandler({ schema: ReposScanBody, policy: [authenticated], handler: async ({ event, platform }) => { const u = await requireUserRow(event) + if (!u.repo_indexing) + throw createError({ statusCode: 403, message: 'Repository indexing is turned off for this account' }) const config = useRuntimeConfig(event) const { db, env } = platform diff --git a/layers/identity/server/presenters/user.ts b/layers/identity/server/presenters/user.ts index 21df83bd..8f656d44 100644 --- a/layers/identity/server/presenters/user.ts +++ b/layers/identity/server/presenters/user.ts @@ -66,5 +66,7 @@ export function mePresenter(u: UserRow) { timezone: u.timezone, stars_synced_at: u.stars_synced_at, onboarded_at: u.onboarded_at, + likes_public: !!u.likes_public, + repo_indexing: !!u.repo_indexing, } } diff --git a/layers/identity/server/routes/auth/github.get.ts b/layers/identity/server/routes/auth/github.get.ts index 084b8ed9..9a4f093c 100644 --- a/layers/identity/server/routes/auth/github.get.ts +++ b/layers/identity/server/routes/auth/github.get.ts @@ -60,17 +60,18 @@ export default defineOAuthGitHubEventHandler({ scopes, }) - // First-time signup: kick off a background scan of the user's public repos - // for SKILL.md files via GitHub code search, indexing each into `skills`. - if (!row.onboarded_at && accessToken) { + // First sign-in: scan the account's public repositories for SKILL.md and + // index what it finds. This is how the registry grows, and the files are + // already public on GitHub. `repo_indexing` is on by default, and /me + // turns it off; an account that turned it off is never scanned again. + if (!row.onboarded_at && accessToken && row.repo_indexing) { const scanPromise = scanOwnedRepos({ login: row.login, userToken: accessToken, db: platform.db, env: platform.env, }).then((result) => { - const warning = ownedRepoScanWarning(result) - if (warning) + if (ownedRepoScanWarning(result)) emitOperationalEvent(createWideEvent({ operation: 'oauth-owned-repo-scan', outcome: 'incomplete' })) }).catch(() => { emitOperationalEvent(createWideEvent({ operation: 'oauth-owned-repo-scan', outcome: 'failed' })) diff --git a/layers/identity/server/utils/liked-list-access.ts b/layers/identity/server/utils/liked-list-access.ts new file mode 100644 index 00000000..d3d98628 --- /dev/null +++ b/layers/identity/server/utils/liked-list-access.ts @@ -0,0 +1,62 @@ +/** + * Who can read one account's liked list at /@login/liked. + * + * `public`: the owner turned the list on, so anyone can read it. + * `owner`: the list is private and the viewer is its owner. + * `hidden`: the list is private and the viewer is someone else, or nobody. + */ +export type LikedListAccess + = | { _tag: 'public' } + | { _tag: 'owner' } + | { _tag: 'hidden' } + +export function likedListAccess(input: { + likesPublic: boolean + ownerId: number + viewerId: number | null +}): LikedListAccess { + if (input.likesPublic) + return { _tag: 'public' } + if (input.viewerId === input.ownerId) + return { _tag: 'owner' } + return { _tag: 'hidden' } +} + +export interface LikedListOwner { + id: number + login: string + name: string | null + avatar: string | null +} + +export type LikedListLookup + = | { _tag: 'visible', owner: LikedListOwner, access: 'public' | 'owner' } + | { _tag: 'not_found' } + +/** + * Resolve the list owner and the viewer's access in one read. + * + * A private list and a missing account both return `not_found`, so a + * visitor cannot learn which accounts keep their list private. + */ +export async function lookupLikedList( + db: D1Database, + login: string, + viewerId: number | null, +): Promise { + const row = await db.prepare( + `SELECT id, login, name, avatar, likes_public FROM users WHERE login = ?1 COLLATE NOCASE`, + ).bind(login).first() + if (!row) + return { _tag: 'not_found' } + + const access = likedListAccess({ likesPublic: row.likes_public === 1, ownerId: row.id, viewerId }) + if (access._tag === 'hidden') + return { _tag: 'not_found' } + + return { + _tag: 'visible', + owner: { id: row.id, login: row.login, name: row.name, avatar: row.avatar }, + access: access._tag, + } +} diff --git a/layers/identity/server/utils/users.ts b/layers/identity/server/utils/users.ts index ab58a156..aad11956 100644 --- a/layers/identity/server/utils/users.ts +++ b/layers/identity/server/utils/users.ts @@ -15,6 +15,10 @@ export interface UserRow { stars_synced_at: number | null onboarded_at: number | null last_login_at: number + /** 1 when anyone can read /@login/liked. 0 keeps it to the owner. */ + likes_public: number + /** 1 when sign-in may scan the account's public repositories for Skills. */ + repo_indexing: number } export interface GitHubProfile { @@ -104,7 +108,7 @@ export async function upsertUserFromGithub( const row = await d.prepare( `SELECT id, github_id, login, name, email, avatar, digest_email, email_opt_in, weekly_opt_out, timezone, - stars_synced_at, onboarded_at, last_login_at + stars_synced_at, onboarded_at, last_login_at, likes_public, repo_indexing FROM users WHERE github_id = ?1`, ).bind(profile.id).first() @@ -117,7 +121,7 @@ export async function getUserById(event: H3Event, id: number): Promise() return row ?? null diff --git a/layers/identity/shared/contracts/account.ts b/layers/identity/shared/contracts/account.ts index 2fa311d4..a43db0ee 100644 --- a/layers/identity/shared/contracts/account.ts +++ b/layers/identity/shared/contracts/account.ts @@ -34,6 +34,32 @@ export const identityMeSchema = z.object({ timezone: z.string(), stars_synced_at: z.number().int().nullable(), onboarded_at: z.number().int().nullable(), + /** Anyone can read /@login/liked when true. */ + likes_public: z.boolean(), + /** Sign-in scans the account's public repositories for Skills when true. */ + repo_indexing: z.boolean(), +}) + +/** + * One privacy switch per request, or both. Sending neither is a 400: an empty + * patch reads as a saved change that changed nothing. + */ +export const identityPrivacyPatchBodySchema = z.object({ + likes_public: z.boolean().optional(), + repo_indexing: z.boolean().optional(), +}).refine(body => body.likes_public !== undefined || body.repo_indexing !== undefined, { + message: 'Name at least one privacy setting', +}) + +/** + * The part of `POST /api/me/repos/scan` the onboarding prompt reads. + * Extra count fields pass through unread. + */ +export const identityOwnedRepoScanResponseSchema = z.object({ + _tag: z.enum(['complete', 'partial', 'auth_failure', 'rate_limited', 'provider_failure']), + reposFound: z.number().int().nonnegative(), + reposSynced: z.number().int().nonnegative(), + reposFailed: z.number().int().nonnegative(), }) export const identitySubscriptionSchema = z.object({ @@ -53,6 +79,8 @@ export const identityMutationResponseSchema = z.object({ }) export type IdentityEmailPatchBody = z.input +export type IdentityPrivacyPatchBody = z.input +export type IdentityOwnedRepoScanResponse = z.output export type IdentityMutationResponse = z.output export interface IdentitySubscriptionRef { diff --git a/migrations/0121_privacy_defaults.sql b/migrations/0121_privacy_defaults.sql new file mode 100644 index 00000000..ab63fbea --- /dev/null +++ b/migrations/0121_privacy_defaults.sql @@ -0,0 +1,10 @@ +-- Two switches, both on by default, both the account's to turn off. +-- +-- `likes_public` keeps /@login/liked public, which is how it already worked, +-- and gives the owner a way to close it. +-- +-- `repo_indexing` keeps the sign-in scan of an account's public repositories, +-- which is how the registry grows. An account that turns it off gets no +-- further scan. +ALTER TABLE users ADD COLUMN likes_public INTEGER NOT NULL DEFAULT 1 CHECK (likes_public IN (0, 1)); +ALTER TABLE users ADD COLUMN repo_indexing INTEGER NOT NULL DEFAULT 1 CHECK (repo_indexing IN (0, 1)); diff --git a/schema.sql b/schema.sql index eb52bd50..2af476fc 100644 --- a/schema.sql +++ b/schema.sql @@ -578,7 +578,9 @@ CREATE TABLE users ( timezone TEXT NOT NULL DEFAULT 'UTC', onboarded_at INTEGER, created_at INTEGER NOT NULL, - last_login_at INTEGER NOT NULL + last_login_at INTEGER NOT NULL, + likes_public INTEGER NOT NULL DEFAULT 1 CHECK (likes_public IN (0, 1)), + repo_indexing INTEGER NOT NULL DEFAULT 1 CHECK (repo_indexing IN (0, 1)) ); CREATE TRIGGER ai_ready_pages_ad AFTER DELETE ON ai_ready_pages BEGIN diff --git a/test/unit/cli-oauth-endpoints.test.ts b/test/unit/cli-oauth-endpoints.test.ts index bc3a61f9..61db6d7f 100644 --- a/test/unit/cli-oauth-endpoints.test.ts +++ b/test/unit/cli-oauth-endpoints.test.ts @@ -42,7 +42,9 @@ describe('cli OAuth token endpoint', () => { timezone TEXT NOT NULL DEFAULT 'UTC', onboarded_at INTEGER, created_at INTEGER NOT NULL, - last_login_at INTEGER NOT NULL + last_login_at INTEGER NOT NULL, + likes_public INTEGER NOT NULL DEFAULT 1, + repo_indexing INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE cli_auth_codes ( code TEXT PRIMARY KEY, diff --git a/test/unit/cli-tokens.test.ts b/test/unit/cli-tokens.test.ts index a9af3321..a2387ecb 100644 --- a/test/unit/cli-tokens.test.ts +++ b/test/unit/cli-tokens.test.ts @@ -63,7 +63,9 @@ describe('cli token sessions', () => { timezone TEXT NOT NULL DEFAULT 'UTC', onboarded_at INTEGER, created_at INTEGER NOT NULL, - last_login_at INTEGER NOT NULL + last_login_at INTEGER NOT NULL, + likes_public INTEGER NOT NULL DEFAULT 1, + repo_indexing INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE cli_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/test/unit/digest-opt-in-address.test.ts b/test/unit/digest-opt-in-address.test.ts index ff9d9270..81c61db1 100644 --- a/test/unit/digest-opt-in-address.test.ts +++ b/test/unit/digest-opt-in-address.test.ts @@ -124,7 +124,9 @@ describe('me email patch endpoint', () => { stars_synced_at INTEGER, onboarded_at INTEGER, created_at INTEGER NOT NULL, - last_login_at INTEGER NOT NULL + last_login_at INTEGER NOT NULL, + likes_public INTEGER NOT NULL DEFAULT 1, + repo_indexing INTEGER NOT NULL DEFAULT 1 ); INSERT INTO users ( id, github_id, login, digest_email, email_opt_in, onboarded_at, diff --git a/test/unit/identity-query-contracts.test.ts b/test/unit/identity-query-contracts.test.ts index db99e0fa..c4bf7e74 100644 --- a/test/unit/identity-query-contracts.test.ts +++ b/test/unit/identity-query-contracts.test.ts @@ -49,6 +49,8 @@ describe('identity account query contracts', () => { timezone: 'Australia/Melbourne', stars_synced_at: null, onboarded_at: null, + likes_public: false, + repo_indexing: true, })).toMatchObject({ login: 'harlan-zw', email_opt_in: false }) expect(identitySubscriptionsSchema.parse({ diff --git a/test/unit/likes-privacy.test.ts b/test/unit/likes-privacy.test.ts new file mode 100644 index 00000000..a14db80c --- /dev/null +++ b/test/unit/likes-privacy.test.ts @@ -0,0 +1,120 @@ +import type { EventHandler, H3Event } from 'h3' +import type { SqliteD1 } from './helpers/d1-sqlite' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' + +const OWNER = { id: 9001, login: 'privacy-owner' } +const VISITOR = { id: 9002, login: 'privacy-visitor' } + +describe('liked list privacy', () => { + let d1: SqliteD1 + let listHandler: EventHandler + let visibilityHandler: EventHandler + let privacyHandler: EventHandler + let meHandler: EventHandler + let scanHandler: EventHandler + + beforeEach(async () => { + vi.resetModules() + vi.stubGlobal('defineEventHandler', (handler: EventHandler) => handler) + vi.stubGlobal('createError', (input: Record) => Object.assign(new Error(String(input.message)), input)) + vi.stubGlobal('setHeader', () => {}) + vi.stubGlobal('getRouterParam', (event: H3Event, key: string) => (event.context.params as Record)[key]) + + d1 = createSqliteD1(allMigrations()) + d1.raw.exec(` + INSERT INTO users (id, github_id, login, created_at, last_login_at) VALUES + (9001, 900100, 'privacy-owner', 1, 1), + (9002, 900200, 'privacy-visitor', 1, 1); + INSERT INTO repos (owner, repo) VALUES ('privacy-test', 'skills'); + INSERT INTO skills (owner, repo, name, display_name, slug, source_resolved) VALUES ('privacy-test', 'skills', 'motion', 'Motion', 'privacy-test/skills/motion', 1); + INSERT INTO skill_likes (user_id, owner, repo, name, created_at) VALUES (9001, 'privacy-test', 'skills', 'motion', 10); + `) + + listHandler = (await import('../../layers/identity/server/api/likes/by-user/[login].get')).default + visibilityHandler = (await import('../../layers/identity/server/api/likes/by-user/[login]/visibility.get')).default + privacyHandler = (await import('../../layers/identity/server/api/me/privacy.patch')).default + meHandler = (await import('../../layers/identity/server/api/me/index.get')).default + scanHandler = (await import('../../layers/identity/server/api/me/repos/scan.post')).default + }) + + afterEach(() => { + d1.close() + vi.unstubAllGlobals() + }) + + it('shows a liked list to everyone until its owner closes it', async () => { + const list = await listHandler(event({ viewer: null, login: 'privacy-owner' })) as { access: string, items: Array<{ name: string }> } + + expect(list.access).toBe('public') + expect(list.items.map(item => item.name)).toEqual(['motion']) + expect(await visibilityHandler(event({ viewer: VISITOR, login: 'privacy-owner' }))).toEqual({ access: 'public' }) + expect(await meHandler(event({ viewer: OWNER }))).toMatchObject({ likes_public: true, repo_indexing: true }) + }) + + it('answers a closed list and a missing account with the same 404', async () => { + await privacyHandler(event({ viewer: OWNER, method: 'PATCH', body: { likes_public: false } })) + + const closed = await listHandler(event({ viewer: null, login: 'privacy-owner' })).catch(e => e) + const missing = await listHandler(event({ viewer: null, login: 'nobody' })).catch(e => e) + + expect({ statusCode: closed.statusCode, message: closed.message }) + .toEqual({ statusCode: missing.statusCode, message: missing.message }) + await expect(visibilityHandler(event({ viewer: VISITOR, login: 'privacy-owner' }))) + .rejects + .toMatchObject({ statusCode: 404 }) + }) + + it('shows a closed list to its owner and marks it owner only', async () => { + await privacyHandler(event({ viewer: OWNER, method: 'PATCH', body: { likes_public: false } })) + + const list = await listHandler(event({ viewer: OWNER, login: 'PRIVACY-OWNER' })) as { access: string, items: Array<{ name: string }> } + + expect(list.access).toBe('owner') + expect(list.items.map(item => item.name)).toEqual(['motion']) + expect(await visibilityHandler(event({ viewer: OWNER, login: 'privacy-owner' }))).toEqual({ access: 'owner' }) + }) + + it('opens the list again after the owner turns it back on', async () => { + await privacyHandler(event({ viewer: OWNER, method: 'PATCH', body: { likes_public: false } })) + await privacyHandler(event({ viewer: OWNER, method: 'PATCH', body: { likes_public: true } })) + + expect(await visibilityHandler(event({ viewer: VISITOR, login: 'privacy-owner' }))).toEqual({ access: 'public' }) + }) + + it('changes only the caller setting', async () => { + await privacyHandler(event({ viewer: VISITOR, method: 'PATCH', body: { likes_public: false } })) + + expect(await visibilityHandler(event({ viewer: null, login: 'privacy-owner' }))).toEqual({ access: 'public' }) + }) + + it('turns repository indexing off without touching the liked list', async () => { + await privacyHandler(event({ viewer: OWNER, method: 'PATCH', body: { repo_indexing: false } })) + + expect(await meHandler(event({ viewer: OWNER }))).toMatchObject({ likes_public: true, repo_indexing: false }) + await expect(scanHandler(event({ viewer: OWNER, method: 'POST', body: {} }))) + .rejects + .toMatchObject({ statusCode: 403 }) + }) + + it.each([{}, { likes_public: 'yes' }, { repo_indexing: 1 }])('rejects the privacy body %j', async (body) => { + await expect(privacyHandler(event({ viewer: OWNER, method: 'PATCH', body }))) + .rejects + .toMatchObject({ statusCode: 400 }) + }) + + function event(input: { + viewer: { id: number, login: string } | null + login?: string + method?: string + body?: unknown + }): H3Event { + vi.stubGlobal('getUserSession', () => Promise.resolve(input.viewer ? { user: input.viewer } : {})) + vi.stubGlobal('readBody', () => Promise.resolve(input.body)) + return { + method: input.method ?? 'GET', + context: { platform: { db: d1.db }, params: { login: input.login } }, + node: { req: { headers: {} } }, + } as unknown as H3Event + } +}) diff --git a/test/unit/owned-repo-scan-notice.test.ts b/test/unit/owned-repo-scan-notice.test.ts new file mode 100644 index 00000000..131f87ea --- /dev/null +++ b/test/unit/owned-repo-scan-notice.test.ts @@ -0,0 +1,31 @@ +import type { IdentityOwnedRepoScanResponse } from '../../layers/identity/shared/contracts/account' +import { describe, expect, it } from 'vitest' +import { ownedRepoScanFailureNotice, ownedRepoScanNotice } from '../../layers/identity/app/utils/owned-repo-scan' + +function result(overrides: Partial): IdentityOwnedRepoScanResponse { + return { _tag: 'complete', reposFound: 0, reposSynced: 0, reposFailed: 0, ...overrides } +} + +describe('owned repository scan notice', () => { + it.each([ + [result({ reposFound: 2, reposSynced: 2 }), { _tag: 'added', message: 'Added Skills from 2 repositories.' }], + [result({ reposFound: 1, reposSynced: 1 }), { _tag: 'added', message: 'Added Skills from 1 repository.' }], + [result({}), { _tag: 'none_found', message: 'No SKILL.md files found in your public repositories.' }], + [result({ reposFound: 3 }), { _tag: 'none_found', message: 'Checked 3 repositories. No new Skills to add.' }], + [result({ reposFound: 3, reposSynced: 2, reposFailed: 1 }), { _tag: 'retry', message: 'Added Skills from 2 repositories. 1 repository failed. Try again later.' }], + [result({ _tag: 'partial', reposFound: 1 }), { _tag: 'retry', message: 'GitHub did not return every result. Try again later.' }], + [result({ _tag: 'rate_limited' }), { _tag: 'retry', message: 'GitHub limited the check. Try again in a few minutes.' }], + [result({ _tag: 'auth_failure' }), { _tag: 'sign_in', message: 'GitHub did not accept your sign-in. Sign in with GitHub again.' }], + ])('describes %j', (input, expected) => { + expect(ownedRepoScanNotice(input)).toEqual(expected) + }) + + it('asks for a new sign-in when the scan request returns 401', () => { + expect(ownedRepoScanFailureNotice(Object.assign(new Error('x'), { statusCode: 401 }))) + .toMatchObject({ _tag: 'sign_in' }) + }) + + it('offers a retry for any other failed request', () => { + expect(ownedRepoScanFailureNotice(new Error('network down'))).toMatchObject({ _tag: 'retry' }) + }) +}) diff --git a/test/unit/skill-likes-endpoints.test.ts b/test/unit/skill-likes-endpoints.test.ts index fec835cd..2a6a9897 100644 --- a/test/unit/skill-likes-endpoints.test.ts +++ b/test/unit/skill-likes-endpoints.test.ts @@ -39,7 +39,9 @@ describe('skill likes endpoints', () => { stars_synced_at INTEGER, onboarded_at INTEGER, created_at INTEGER NOT NULL, - last_login_at INTEGER NOT NULL + last_login_at INTEGER NOT NULL, + likes_public INTEGER NOT NULL DEFAULT 1, + repo_indexing INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE skill_likes ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,