From 3a55f5eba71daa60697c12bb25364871ec0db17d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 17 Sep 2026 01:08:04 +1000 Subject: [PATCH 1/2] feat(identity): ask before indexing repositories and hide likes Sign-in scanned a new account's public repositories and added every SKILL.md to the registry without asking. The onboarding page now asks first, and one click runs the same scan. The /@login/liked list was public for every account with no way to turn it off. It is now private unless the owner turns it on in /me. Migration 0119 keeps existing accounts private, since they never consented either. --- app/pages/@[login]/index.vue | 24 ++++ app/pages/@[login]/liked.vue | 13 ++ .../app/components/OwnedSkillsPrompt.vue | 123 ++++++++++++++++++ layers/identity/app/pages/me/index.vue | 39 ++++++ .../app/pages/onboarding/discover.vue | 7 + layers/identity/app/queries/account.ts | 14 ++ layers/identity/app/utils/owned-repo-scan.ts | 64 +++++++++ .../server/api/likes/by-user/[login].get.ts | 20 ++- .../likes/by-user/[login]/visibility.get.ts | 23 ++++ .../identity/server/api/me/privacy.patch.ts | 17 +++ layers/identity/server/presenters/user.ts | 1 + .../identity/server/routes/auth/github.get.ts | 23 ---- .../server/utils/liked-list-access.ts | 62 +++++++++ layers/identity/server/utils/users.ts | 6 +- layers/identity/shared/contracts/account.ts | 19 +++ migrations/0119_likes_public.sql | 4 + schema.sql | 3 +- test/unit/cli-oauth-endpoints.test.ts | 3 +- test/unit/cli-tokens.test.ts | 3 +- test/unit/digest-opt-in-address.test.ts | 3 +- test/unit/identity-query-contracts.test.ts | 1 + test/unit/likes-privacy.test.ts | 113 ++++++++++++++++ test/unit/owned-repo-scan-notice.test.ts | 31 +++++ test/unit/scan-owned-repos.test.ts | 11 +- test/unit/skill-likes-endpoints.test.ts | 3 +- 25 files changed, 586 insertions(+), 44 deletions(-) create mode 100644 layers/identity/app/components/OwnedSkillsPrompt.vue create mode 100644 layers/identity/app/utils/owned-repo-scan.ts create mode 100644 layers/identity/server/api/likes/by-user/[login]/visibility.get.ts create mode 100644 layers/identity/server/api/me/privacy.patch.ts create mode 100644 layers/identity/server/utils/liked-list-access.ts create mode 100644 migrations/0119_likes_public.sql create mode 100644 test/unit/likes-privacy.test.ts create mode 100644 test/unit/owned-repo-scan-notice.test.ts 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..06079db3 --- /dev/null +++ b/layers/identity/app/components/OwnedSkillsPrompt.vue @@ -0,0 +1,123 @@ + + + diff --git a/layers/identity/app/pages/me/index.vue b/layers/identity/app/pages/me/index.vue index 2bdbe497..dd9b7f3a 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,18 @@ const saveEmailMutation = useNuxtMutation({ + mutation: async (body) => { + const result = await rpc.execute(identityAccountQueries.savePrivacy(), body) + await refreshAccount() + return result + }, + onError: actionFailed('save your liked Skills setting'), +}) +async function setLikesPublic(likesPublic: boolean) { + await savePrivacyMutation.mutateSafe({ likes_public: likesPublic }) +} + const removeLikeMutation = useNuxtMutation({ mutation: async (skill) => { const path = `/api/me/likes/${skill.owner}/${skill.repo}/${skill.name}` @@ -392,6 +405,32 @@ 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..ef662a70 --- /dev/null +++ b/layers/identity/server/api/me/privacy.patch.ts @@ -0,0 +1,17 @@ +import { defineApiHandler } from '#shared/server/handler' +import { identityMutationResponseSchema, identityPrivacyPatchBodySchema } from '../../../shared/contracts/account' +import { authenticated } from '../../policies/authenticated' +import { requireUserRow } from '../../utils/users' + +export default defineApiHandler({ + schema: identityPrivacyPatchBodySchema, + policy: [authenticated], + response: identityMutationResponseSchema, + handler: async ({ event, body, platform }) => { + const u = await requireUserRow(event) + await platform.db.prepare( + `UPDATE users SET likes_public = ?1 WHERE id = ?2`, + ).bind(body.likes_public ? 1 : 0, u.id).run() + return { ok: true as const } + }, +}) diff --git a/layers/identity/server/presenters/user.ts b/layers/identity/server/presenters/user.ts index 21df83bd..c063571e 100644 --- a/layers/identity/server/presenters/user.ts +++ b/layers/identity/server/presenters/user.ts @@ -66,5 +66,6 @@ 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, } } diff --git a/layers/identity/server/routes/auth/github.get.ts b/layers/identity/server/routes/auth/github.get.ts index 084b8ed9..5b9e75a9 100644 --- a/layers/identity/server/routes/auth/github.get.ts +++ b/layers/identity/server/routes/auth/github.get.ts @@ -1,6 +1,5 @@ import { z } from 'zod' import { fetchVerifiedPrimaryEmail } from '../../utils/github-emails' -import { ownedRepoScanWarning, scanOwnedRepos } from '../../utils/scan-owned-repos' import { upsertUserFromGithub } from '../../utils/users' import { handleWatchAction } from '../../utils/watch-actions' @@ -60,28 +59,6 @@ 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) { - const scanPromise = scanOwnedRepos({ - login: row.login, - userToken: accessToken, - db: platform.db, - env: platform.env, - }).then((result) => { - const warning = ownedRepoScanWarning(result) - if (warning) - emitOperationalEvent(createWideEvent({ operation: 'oauth-owned-repo-scan', outcome: 'incomplete' })) - }).catch(() => { - emitOperationalEvent(createWideEvent({ operation: 'oauth-owned-repo-scan', outcome: 'failed' })) - }) - const cfCtx = (event.context as { cloudflare?: { context?: { waitUntil?: (p: Promise) => void } } }).cloudflare?.context - if (cfCtx?.waitUntil) - cfCtx.waitUntil(scanPromise) - else - void scanPromise - } - await setUserSession(event, { user: { id: row.id, 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..ebec60a8 100644 --- a/layers/identity/server/utils/users.ts +++ b/layers/identity/server/utils/users.ts @@ -15,6 +15,8 @@ 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 } export interface GitHubProfile { @@ -104,7 +106,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 FROM users WHERE github_id = ?1`, ).bind(profile.id).first() @@ -117,7 +119,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..aeab1192 100644 --- a/layers/identity/shared/contracts/account.ts +++ b/layers/identity/shared/contracts/account.ts @@ -34,6 +34,23 @@ 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(), +}) + +export const identityPrivacyPatchBodySchema = z.object({ + likes_public: z.boolean(), +}) + +/** + * 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 +70,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/0119_likes_public.sql b/migrations/0119_likes_public.sql new file mode 100644 index 00000000..86272e4a --- /dev/null +++ b/migrations/0119_likes_public.sql @@ -0,0 +1,4 @@ +-- /@login/liked was public for every account, and nobody chose that. +-- The list is now private unless its owner turns it on. Existing accounts get +-- the same private default, because they never gave consent either. +ALTER TABLE users ADD COLUMN likes_public INTEGER NOT NULL DEFAULT 0 CHECK (likes_public IN (0, 1)); diff --git a/schema.sql b/schema.sql index eb52bd50..bda1fbd9 100644 --- a/schema.sql +++ b/schema.sql @@ -578,7 +578,8 @@ 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 0 CHECK (likes_public 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..2d51ef3c 100644 --- a/test/unit/cli-oauth-endpoints.test.ts +++ b/test/unit/cli-oauth-endpoints.test.ts @@ -42,7 +42,8 @@ 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 0 ); 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..97cfbb6f 100644 --- a/test/unit/cli-tokens.test.ts +++ b/test/unit/cli-tokens.test.ts @@ -63,7 +63,8 @@ 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 0 ); 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..b7670197 100644 --- a/test/unit/digest-opt-in-address.test.ts +++ b/test/unit/digest-opt-in-address.test.ts @@ -124,7 +124,8 @@ 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 0 ); 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..b2947e40 100644 --- a/test/unit/identity-query-contracts.test.ts +++ b/test/unit/identity-query-contracts.test.ts @@ -49,6 +49,7 @@ describe('identity account query contracts', () => { timezone: 'Australia/Melbourne', stars_synced_at: null, onboarded_at: null, + likes_public: false, })).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..8c8a0296 --- /dev/null +++ b/test/unit/likes-privacy.test.ts @@ -0,0 +1,113 @@ +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 + + 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 + }) + + afterEach(() => { + d1.close() + vi.unstubAllGlobals() + }) + + it('keeps an existing account private after the migration', async () => { + await expect(listHandler(event({ viewer: null, login: 'privacy-owner' }))) + .rejects + .toMatchObject({ statusCode: 404 }) + await expect(visibilityHandler(event({ viewer: VISITOR, login: 'privacy-owner' }))) + .rejects + .toMatchObject({ statusCode: 404 }) + }) + + it('answers a private list and a missing account with the same 404', async () => { + const privateList = 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: privateList.statusCode, message: privateList.message }) + .toEqual({ statusCode: missing.statusCode, message: missing.message }) + }) + + it('shows a private list to its owner and marks it owner only', async () => { + 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 to everyone after the owner turns it on, and closes it again', async () => { + await privacyHandler(event({ viewer: OWNER, method: 'PATCH', body: { likes_public: true } })) + + const list = await listHandler(event({ viewer: null, login: 'privacy-owner' })) as { access: string, items: unknown[] } + expect(list.access).toBe('public') + expect(list.items).toHaveLength(1) + expect(await visibilityHandler(event({ viewer: VISITOR, login: 'privacy-owner' }))).toEqual({ access: 'public' }) + expect(await meHandler(event({ viewer: OWNER }))).toMatchObject({ likes_public: true }) + + await privacyHandler(event({ viewer: OWNER, method: 'PATCH', body: { likes_public: false } })) + + await expect(listHandler(event({ viewer: VISITOR, login: 'privacy-owner' }))) + .rejects + .toMatchObject({ statusCode: 404 }) + }) + + it('changes only the caller setting', async () => { + await privacyHandler(event({ viewer: VISITOR, method: 'PATCH', body: { likes_public: true } })) + + await expect(listHandler(event({ viewer: null, login: 'privacy-owner' }))) + .rejects + .toMatchObject({ statusCode: 404 }) + }) + + it.each([{}, { likes_public: 'yes' }])('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/scan-owned-repos.test.ts b/test/unit/scan-owned-repos.test.ts index e9efb0c9..64aa2657 100644 --- a/test/unit/scan-owned-repos.test.ts +++ b/test/unit/scan-owned-repos.test.ts @@ -247,6 +247,7 @@ describe('owned GitHub repository discovery', () => { }) }) +<<<<<<< HEAD it('searches each organisation the account belongs to, not just the account', async () => { const fetch = vi.fn(async (url: string) => { const query = decodeURIComponent(new URL(url).searchParams.get('q') ?? '') @@ -314,6 +315,9 @@ describe('owned GitHub repository discovery', () => { }) it('maps API success and OAuth warnings from the tagged outcome', () => { +======= + it('maps API success from the tagged outcome', () => { +>>>>>>> ca3ba8c1 (feat(identity): ask before indexing repositories and hide likes) const complete = { _tag: 'complete' as const, ...emptyResultCounts() } const partial = { _tag: 'partial' as const, @@ -323,13 +327,6 @@ describe('owned GitHub repository discovery', () => { expect(ownedRepoScanResponse(complete)).toMatchObject({ ok: true, _tag: 'complete' }) expect(ownedRepoScanResponse(partial)).toMatchObject({ ok: false, _tag: 'partial' }) - expect(ownedRepoScanWarning(complete)).toBeNull() - expect(ownedRepoScanWarning(partial)).toEqual({ - outcome: 'partial', - reason: 'incomplete_results', - reposFound: 0, - reposFailed: 0, - }) }) it.each([ diff --git a/test/unit/skill-likes-endpoints.test.ts b/test/unit/skill-likes-endpoints.test.ts index fec835cd..f3ac1936 100644 --- a/test/unit/skill-likes-endpoints.test.ts +++ b/test/unit/skill-likes-endpoints.test.ts @@ -39,7 +39,8 @@ 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 0 ); CREATE TABLE skill_likes ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, From 6b2db0cdecc086bbb49351ec6cf18aba7c4d4228 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 22 Sep 2026 12:40:26 +1000 Subject: [PATCH 2/2] feat(identity): make the privacy switches opt-out, not opt-in Both settings are on by default and the account can turn either off. Sign-in still scans the account's public repositories for SKILL.md and indexes what it finds, because that is how the registry grows and the files are already public on GitHub. `repo_indexing` turns it off, and the scan endpoint then refuses too, so one switch decides. The liked list stays public, which is how it already worked, and `likes_public` closes it. A closed list answers 404 to everyone but its owner, the same answer as a missing account. No shared link breaks on migration. The onboarding prompt is now a notice with a way out rather than a question. --- .../app/components/OwnedSkillsPrompt.vue | 125 ++++++++++-------- layers/identity/app/pages/me/index.vue | 17 ++- .../identity/server/api/me/privacy.patch.ts | 21 ++- .../identity/server/api/me/repos/scan.post.ts | 9 ++ layers/identity/server/presenters/user.ts | 1 + .../identity/server/routes/auth/github.get.ts | 24 ++++ layers/identity/server/utils/users.ts | 6 +- layers/identity/shared/contracts/account.ts | 11 +- migrations/0119_likes_public.sql | 4 - migrations/0121_privacy_defaults.sql | 10 ++ schema.sql | 3 +- test/unit/cli-oauth-endpoints.test.ts | 3 +- test/unit/cli-tokens.test.ts | 3 +- test/unit/digest-opt-in-address.test.ts | 3 +- test/unit/identity-query-contracts.test.ts | 1 + test/unit/likes-privacy.test.ts | 57 ++++---- test/unit/scan-owned-repos.test.ts | 11 +- test/unit/skill-likes-endpoints.test.ts | 3 +- 18 files changed, 211 insertions(+), 101 deletions(-) delete mode 100644 migrations/0119_likes_public.sql create mode 100644 migrations/0121_privacy_defaults.sql diff --git a/layers/identity/app/components/OwnedSkillsPrompt.vue b/layers/identity/app/components/OwnedSkillsPrompt.vue index 06079db3..1adee645 100644 --- a/layers/identity/app/components/OwnedSkillsPrompt.vue +++ b/layers/identity/app/components/OwnedSkillsPrompt.vue @@ -4,25 +4,33 @@ import { identityAccountQueries } from '../queries/account' import { ownedRepoScanFailureNotice, ownedRepoScanNotice } from '../utils/owned-repo-scan' /** - * Asks before skilld indexes the signed-in account's public repositories. - * Sign-in never starts this scan; only the button does. + * Tells the signed-in account that its public repositories are indexed, and + * offers the switch that stops it. + * + * Sign-in already ran the scan, because SKILL.md files in a public repository + * are already public and indexing them is how the registry grows. So this is a + * notice with a way out, not a question. */ const { login } = defineProps<{ login: string }>() type PromptState - = | { _tag: 'asking' } + = | { _tag: 'indexing' } | { _tag: 'scanning' } | { _tag: 'finished', notice: OwnedRepoScanNotice } - | { _tag: 'dismissed' } + | { _tag: 'turned_off' } -const state = ref({ _tag: 'asking' }) +const state = ref({ _tag: 'indexing' }) const rpc = useNuxtRpc() const scanMutation = useNuxtMutation({ mutation: () => rpc.execute(identityAccountQueries.scanOwnedRepos()), }) -async function addSkills() { +const privacyMutation = useNuxtMutation({ + mutation: () => rpc.execute(identityAccountQueries.savePrivacy(), { repo_indexing: false }), +}) + +async function checkNow() { state.value = { _tag: 'scanning' } const outcome = await scanMutation.mutateSafe() state.value = { @@ -31,6 +39,12 @@ async function addSkills() { } } +async function turnOff() { + const outcome = await privacyMutation.mutateSafe() + if (outcome._tag === 'ok') + state.value = { _tag: 'turned_off' } +} + const route = useRoute() const { loginUrl } = useAuth() const signInPath = computed(() => loginUrl({ returnTo: route.fullPath })) @@ -38,33 +52,40 @@ const signInPath = computed(() => loginUrl({ returnTo: route.fullPath })) diff --git a/layers/identity/app/pages/me/index.vue b/layers/identity/app/pages/me/index.vue index dd9b7f3a..244d12de 100644 --- a/layers/identity/app/pages/me/index.vue +++ b/layers/identity/app/pages/me/index.vue @@ -66,11 +66,14 @@ const savePrivacyMutation = useNuxtMutation({ mutation: async (skill) => { @@ -429,6 +432,18 @@ function fmtDate(ts: number | null | undefined): string { label="View liked Skills page" />
+
+ +

diff --git a/layers/identity/server/api/me/privacy.patch.ts b/layers/identity/server/api/me/privacy.patch.ts index ef662a70..cc7a8ee9 100644 --- a/layers/identity/server/api/me/privacy.patch.ts +++ b/layers/identity/server/api/me/privacy.patch.ts @@ -3,15 +3,32 @@ import { identityMutationResponseSchema, identityPrivacyPatchBodySchema } from ' 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 likes_public = ?1 WHERE id = ?2`, - ).bind(body.likes_public ? 1 : 0, u.id).run() + `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 c063571e..8f656d44 100644 --- a/layers/identity/server/presenters/user.ts +++ b/layers/identity/server/presenters/user.ts @@ -67,5 +67,6 @@ export function mePresenter(u: UserRow) { 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 5b9e75a9..9a4f093c 100644 --- a/layers/identity/server/routes/auth/github.get.ts +++ b/layers/identity/server/routes/auth/github.get.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { fetchVerifiedPrimaryEmail } from '../../utils/github-emails' +import { ownedRepoScanWarning, scanOwnedRepos } from '../../utils/scan-owned-repos' import { upsertUserFromGithub } from '../../utils/users' import { handleWatchAction } from '../../utils/watch-actions' @@ -59,6 +60,29 @@ export default defineOAuthGitHubEventHandler({ scopes, }) + // 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) => { + if (ownedRepoScanWarning(result)) + emitOperationalEvent(createWideEvent({ operation: 'oauth-owned-repo-scan', outcome: 'incomplete' })) + }).catch(() => { + emitOperationalEvent(createWideEvent({ operation: 'oauth-owned-repo-scan', outcome: 'failed' })) + }) + const cfCtx = (event.context as { cloudflare?: { context?: { waitUntil?: (p: Promise) => void } } }).cloudflare?.context + if (cfCtx?.waitUntil) + cfCtx.waitUntil(scanPromise) + else + void scanPromise + } + await setUserSession(event, { user: { id: row.id, diff --git a/layers/identity/server/utils/users.ts b/layers/identity/server/utils/users.ts index ebec60a8..aad11956 100644 --- a/layers/identity/server/utils/users.ts +++ b/layers/identity/server/utils/users.ts @@ -17,6 +17,8 @@ export interface UserRow { 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 { @@ -106,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, likes_public + stars_synced_at, onboarded_at, last_login_at, likes_public, repo_indexing FROM users WHERE github_id = ?1`, ).bind(profile.id).first() @@ -119,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 aeab1192..a43db0ee 100644 --- a/layers/identity/shared/contracts/account.ts +++ b/layers/identity/shared/contracts/account.ts @@ -36,10 +36,19 @@ export const identityMeSchema = z.object({ 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(), + 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', }) /** diff --git a/migrations/0119_likes_public.sql b/migrations/0119_likes_public.sql deleted file mode 100644 index 86272e4a..00000000 --- a/migrations/0119_likes_public.sql +++ /dev/null @@ -1,4 +0,0 @@ --- /@login/liked was public for every account, and nobody chose that. --- The list is now private unless its owner turns it on. Existing accounts get --- the same private default, because they never gave consent either. -ALTER TABLE users ADD COLUMN likes_public INTEGER NOT NULL DEFAULT 0 CHECK (likes_public IN (0, 1)); 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 bda1fbd9..2af476fc 100644 --- a/schema.sql +++ b/schema.sql @@ -579,7 +579,8 @@ CREATE TABLE users ( onboarded_at INTEGER, created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL, - likes_public INTEGER NOT NULL DEFAULT 0 CHECK (likes_public IN (0, 1)) + 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 2d51ef3c..61db6d7f 100644 --- a/test/unit/cli-oauth-endpoints.test.ts +++ b/test/unit/cli-oauth-endpoints.test.ts @@ -43,7 +43,8 @@ describe('cli OAuth token endpoint', () => { onboarded_at INTEGER, created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL, - likes_public INTEGER NOT NULL DEFAULT 0 + 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 97cfbb6f..a2387ecb 100644 --- a/test/unit/cli-tokens.test.ts +++ b/test/unit/cli-tokens.test.ts @@ -64,7 +64,8 @@ describe('cli token sessions', () => { onboarded_at INTEGER, created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL, - likes_public INTEGER NOT NULL DEFAULT 0 + 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 b7670197..81c61db1 100644 --- a/test/unit/digest-opt-in-address.test.ts +++ b/test/unit/digest-opt-in-address.test.ts @@ -125,7 +125,8 @@ describe('me email patch endpoint', () => { onboarded_at INTEGER, created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL, - likes_public INTEGER NOT NULL DEFAULT 0 + 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 b2947e40..c4bf7e74 100644 --- a/test/unit/identity-query-contracts.test.ts +++ b/test/unit/identity-query-contracts.test.ts @@ -50,6 +50,7 @@ describe('identity account query contracts', () => { 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 index 8c8a0296..a14db80c 100644 --- a/test/unit/likes-privacy.test.ts +++ b/test/unit/likes-privacy.test.ts @@ -12,6 +12,7 @@ describe('liked list privacy', () => { let visibilityHandler: EventHandler let privacyHandler: EventHandler let meHandler: EventHandler + let scanHandler: EventHandler beforeEach(async () => { vi.resetModules() @@ -34,6 +35,7 @@ describe('liked list privacy', () => { 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(() => { @@ -41,24 +43,31 @@ describe('liked list privacy', () => { vi.unstubAllGlobals() }) - it('keeps an existing account private after the migration', async () => { - await expect(listHandler(event({ viewer: null, login: 'privacy-owner' }))) - .rejects - .toMatchObject({ statusCode: 404 }) - await expect(visibilityHandler(event({ viewer: VISITOR, login: 'privacy-owner' }))) - .rejects - .toMatchObject({ statusCode: 404 }) + 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 private list and a missing account with the same 404', async () => { - const privateList = await listHandler(event({ viewer: null, login: 'privacy-owner' })).catch(e => e) + 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: privateList.statusCode, message: privateList.message }) + 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 private list to its owner and marks it owner only', async () => { + 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') @@ -66,31 +75,29 @@ describe('liked list privacy', () => { expect(await visibilityHandler(event({ viewer: OWNER, login: 'privacy-owner' }))).toEqual({ access: 'owner' }) }) - it('opens the list to everyone after the owner turns it on, and closes it again', async () => { + 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 } })) - const list = await listHandler(event({ viewer: null, login: 'privacy-owner' })) as { access: string, items: unknown[] } - expect(list.access).toBe('public') - expect(list.items).toHaveLength(1) expect(await visibilityHandler(event({ viewer: VISITOR, login: 'privacy-owner' }))).toEqual({ access: 'public' }) - expect(await meHandler(event({ viewer: OWNER }))).toMatchObject({ likes_public: true }) + }) - await privacyHandler(event({ viewer: OWNER, method: 'PATCH', body: { likes_public: false } })) + it('changes only the caller setting', async () => { + await privacyHandler(event({ viewer: VISITOR, method: 'PATCH', body: { likes_public: false } })) - await expect(listHandler(event({ viewer: VISITOR, login: 'privacy-owner' }))) - .rejects - .toMatchObject({ statusCode: 404 }) + expect(await visibilityHandler(event({ viewer: null, login: 'privacy-owner' }))).toEqual({ access: 'public' }) }) - it('changes only the caller setting', async () => { - await privacyHandler(event({ viewer: VISITOR, method: 'PATCH', body: { likes_public: true } })) + it('turns repository indexing off without touching the liked list', async () => { + await privacyHandler(event({ viewer: OWNER, method: 'PATCH', body: { repo_indexing: false } })) - await expect(listHandler(event({ viewer: null, login: 'privacy-owner' }))) + 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: 404 }) + .toMatchObject({ statusCode: 403 }) }) - it.each([{}, { likes_public: 'yes' }])('rejects the privacy body %j', async (body) => { + 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 }) diff --git a/test/unit/scan-owned-repos.test.ts b/test/unit/scan-owned-repos.test.ts index 64aa2657..e9efb0c9 100644 --- a/test/unit/scan-owned-repos.test.ts +++ b/test/unit/scan-owned-repos.test.ts @@ -247,7 +247,6 @@ describe('owned GitHub repository discovery', () => { }) }) -<<<<<<< HEAD it('searches each organisation the account belongs to, not just the account', async () => { const fetch = vi.fn(async (url: string) => { const query = decodeURIComponent(new URL(url).searchParams.get('q') ?? '') @@ -315,9 +314,6 @@ describe('owned GitHub repository discovery', () => { }) it('maps API success and OAuth warnings from the tagged outcome', () => { -======= - it('maps API success from the tagged outcome', () => { ->>>>>>> ca3ba8c1 (feat(identity): ask before indexing repositories and hide likes) const complete = { _tag: 'complete' as const, ...emptyResultCounts() } const partial = { _tag: 'partial' as const, @@ -327,6 +323,13 @@ describe('owned GitHub repository discovery', () => { expect(ownedRepoScanResponse(complete)).toMatchObject({ ok: true, _tag: 'complete' }) expect(ownedRepoScanResponse(partial)).toMatchObject({ ok: false, _tag: 'partial' }) + expect(ownedRepoScanWarning(complete)).toBeNull() + expect(ownedRepoScanWarning(partial)).toEqual({ + outcome: 'partial', + reason: 'incomplete_results', + reposFound: 0, + reposFailed: 0, + }) }) it.each([ diff --git a/test/unit/skill-likes-endpoints.test.ts b/test/unit/skill-likes-endpoints.test.ts index f3ac1936..2a6a9897 100644 --- a/test/unit/skill-likes-endpoints.test.ts +++ b/test/unit/skill-likes-endpoints.test.ts @@ -40,7 +40,8 @@ describe('skill likes endpoints', () => { onboarded_at INTEGER, created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL, - likes_public INTEGER NOT NULL DEFAULT 0 + 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,