From d24ad2fc9778372e67d45746ab5a8a624076d70d Mon Sep 17 00:00:00 2001
From: Harlan Wilton
Date: Mon, 12 Oct 2026 12:26:32 +1100
Subject: [PATCH 01/14] fix(onboarding): distinguish sign-in clicks and define
account copy
---
COPY.md | 21 ++++++++++++++++---
app/plugins/signup-clicks.client.ts | 17 +++++++++++++++
.../app/composables/useSignupEvents.ts | 2 +-
.../identity/server/api/events/signup.post.ts | 4 ++--
.../identity/server/policies/signup-event.ts | 7 +++++++
shared/checkin-external.ts | 2 +-
shared/signup-analytics.ts | 15 +++++++++++++
test/unit/signup-analytics.test.ts | 19 ++++++++++++++++-
test/unit/signup-event-endpoint.test.ts | 10 +++++++++
9 files changed, 89 insertions(+), 8 deletions(-)
create mode 100644 app/plugins/signup-clicks.client.ts
create mode 100644 layers/identity/server/policies/signup-event.ts
diff --git a/COPY.md b/COPY.md
index ae22d546..39d3ff29 100644
--- a/COPY.md
+++ b/COPY.md
@@ -51,11 +51,11 @@ These exact strings. Do not paraphrase them per page.
| Demo groups | Films and launch videos · Landing pages · UI components · Diagrams and explainers | guidance only: `DEMO_GROUPS` in `shared/demo-groups.ts`: the `/skills/demos` group headings and their lines |
| Writing demo | Writing · Articles, READMEs, and PR descriptions. · Files · Updated · Diff · Markdown · No newline at end of file | guidance only: writing support on the existing demos board. Show file buttons with Markdown icons and one Diff toggle with an icon. Show the updated document by default. Diff compares the original seed and updated Markdown with line numbers and syntax highlighting. Fit short documents to their content and cap long documents. Keep the no-Skill baseline in recording data for later evaluations. No decorative text. |
| Writing demo example | Example data · Example data. No Skills were run. | guidance only: each known writing Skill uses `/skills/demos///`, with `/{name}` as its heading. All candidate rows share handwritten UI fixtures. Keep the disclosure visible. Show no recording metadata. Reuse the demo footer with Skill links, Run it yourself, and Copy demo link. The run prompt supplies the original fixture documents. README and PR documents use GitHub-style Markdown with the shared Skill renderer and rangi highlighter. Diff toggles the Markdown comparison on and off. |
-| Demo actions | Run it yourself · Agent prompt · Terminal · Copy Agent prompt · Copy command · Copied · Copy demo link · Copied demo link | guidance only: demo stages. The Agent prompt includes the recorded task. The terminal option loads the Skill. Run is hidden on phones. |
+| Demo actions | Run it yourself · Agent prompt · Terminal · Copy Agent prompt · Copy command · Copied · Copy demo link · Copied demo link | guidance only: demo stages. The Agent prompt includes the recorded task. The terminal option loads the Skill. Run is available on phones. |
| Demo stage | Open the demo | guidance only: the demo stage link. The hero shows the output, author row, and this link only. On the homepage it opens the demo page; on a demo page it opens the Skill page Demo panel |
| Home demo carousel | Previous demo · Next demo · {n} of {total} | guidance only: the manual homepage demo controls. The position is live text for screen readers. |
-| Demo page prompt | Prompt | guidance only: the selected demo page keeps its recorded task in an expandable disclosure beside the model and effort, above the output frame. |
-| Demo panel | Demo · Prompt · Open live demo · Open in a new tab · Run it yourself · All demos | guidance only: the Skill page Demo panel. "Show screenshots" replaces "Open live demo" while the live output shows. "All demos" opens the demo page. It hides for a Skill page only demo, and while the Skill holds a run check flag |
+| Demo page prompt | Prompt | guidance only: full and compact demo stages keep the recorded task in a closed disclosure above the output frame. |
+| Demo panel | Demo · Prompt · Open live demo · Open in a new tab · Run it yourself · All demos | guidance only: the Skill page Demo panel. Prompt and setup start in a closed disclosure. "Show screenshots" replaces "Open live demo" while the live output shows. "All demos" opens the demo page. It hides for a Skill page only demo, and while the Skill holds a run check flag |
| Demo token usage | {n} tokens · Input: {input}; cached input: {cached}; output: {output} | guidance only: recording metadata. Counts come from recorded usage. Cached input is included in input. Omit unavailable counts. |
| Demo provenance | {Agent logo} {model}, {effort} effort | guidance only: the recording line on the Skill page panel and the homepage stage. Screen readers and the title get "Recorded with {agent}, {model}, {effort} effort". Omit effort when the recording has no evidence for it. No date and no GitHub link: the Skill page is where visitors inspect a Skill |
| Demo outdated | Recorded on an older version of this Skill. | guidance only: under the Demo panel when the Skill moved past the recorded commit |
@@ -180,6 +180,21 @@ The weekly card uses `Trending skills this week` as its title and links to the t
Each row links the Skill, the dev count, and `Source`. Keep the run command beside the source link.
Use `7-day social mentions · {date}` as the footer. Format the UTC date as `2 Oct 2026`.
+## Account entry and setup
+
+| Surface | Canonical strings | Use |
+| --- | --- | --- |
+| Login | Sign in to skilld · Use your GitHub account. New here? The same step creates your account. · Continue with GitHub | One account entry creates an account or signs in. Preserve the requested action and return page. |
+| Login benefits | Your skills · Like Skills to find them again in your account. · Watch for changes · Watch the Repositories you use and see what changed. · Email updates · Choose the weekly, monthly watched changes, or neither. | Three account benefits. Running Skills remains anonymous. |
+| Login permissions | We ask for your public profile and email address. | Keep permission scope separate from account benefits. |
+| Login intent | Sign in to like this Skill. · Sign in to watch this Repository. · Sign in to watch this collection. | Explain the action carried into authentication. |
+| Login meta | Sign in with GitHub to keep liked Skills, watch for changes, and choose your emails. | Login page description. |
+| Like needs a Skill | Open a Skill before liking it. | A Repository with several Skills cannot imply which Skill to like. |
+| Star import | We check up to 1,000 starred GitHub Repositories against the registry. Choose which to watch. Email updates are optional. | Match registry coverage, regardless of Repository name. |
+| Star import empty | Find Skills from your GitHub stars · Import stars · None of the checked Repositories have Skills in the registry yet. · Browse curated Skills, then like one to keep it in your account. | Offer discovery when imported stars have no matching Skills. |
+| Star import partial | Checked the first 1,000 GitHub stars. Some starred Repositories were not checked. | The page limit leaves coverage partial. |
+| Account without likes, with watches | Your watched Repositories are ready · {n} watched {Repository/Repositories}. Like a Skill to keep it here. · Browse skills · View watched Repositories | Show existing account value when the liked Skills view is empty. |
+
## Register by context
| Context | Register | Example |
diff --git a/app/plugins/signup-clicks.client.ts b/app/plugins/signup-clicks.client.ts
new file mode 100644
index 00000000..f2d7b6a8
--- /dev/null
+++ b/app/plugins/signup-clicks.client.ts
@@ -0,0 +1,17 @@
+import { useEventListener } from '@vueuse/core'
+import { signupClickEvent } from '#shared/signup-analytics'
+
+export default defineNuxtPlugin(() => {
+ const recordSignup = useSignupEvents()
+ useEventListener(document, 'click', (event) => {
+ // Page loads, prefetches, and programmatic DOM clicks are not click evidence.
+ if (!event.isTrusted || !(event.target instanceof Element))
+ return
+ const link = event.target.closest('a[href]')
+ if (!link)
+ return
+ const point = signupClickEvent(link.href, window.location.origin)
+ if (point)
+ recordSignup(point)
+ })
+})
diff --git a/layers/identity/app/composables/useSignupEvents.ts b/layers/identity/app/composables/useSignupEvents.ts
index 92613b0c..6a531589 100644
--- a/layers/identity/app/composables/useSignupEvents.ts
+++ b/layers/identity/app/composables/useSignupEvents.ts
@@ -3,7 +3,7 @@ import type { SignupBrowserEvent } from '#shared/signup-analytics'
export function useSignupEvents() {
const { loggedIn } = useUserSession()
return (body: SignupBrowserEvent): void => {
- if (!loggedIn.value)
+ if (!loggedIn.value && body.stage !== 'oauth')
return
// Keep the action independent of analytics and survive same-tab redirects.
void $fetch('/api/events/signup', { method: 'POST', body, retry: false, keepalive: true }).catch(() => {
diff --git a/layers/identity/server/api/events/signup.post.ts b/layers/identity/server/api/events/signup.post.ts
index dd17c4da..a5f98ce8 100644
--- a/layers/identity/server/api/events/signup.post.ts
+++ b/layers/identity/server/api/events/signup.post.ts
@@ -1,12 +1,12 @@
import { defineApiHandler } from '#shared/server/handler'
import { SignupBrowserEvent } from '#shared/signup-analytics'
import { identityMutationResponseSchema } from '../../../shared/contracts/account'
-import { authenticated } from '../../policies/authenticated'
+import { signupEventAllowed } from '../../policies/signup-event'
import { emitSignupEvent } from '../../utils/signup-analytics'
export default defineApiHandler({
schema: SignupBrowserEvent,
- policy: [authenticated],
+ policy: [signupEventAllowed],
response: identityMutationResponseSchema,
handler: ({ event, body }) => emitSignupEvent(event, body),
presenter: () => ({ ok: true as const }),
diff --git a/layers/identity/server/policies/signup-event.ts b/layers/identity/server/policies/signup-event.ts
new file mode 100644
index 00000000..35e4793a
--- /dev/null
+++ b/layers/identity/server/policies/signup-event.ts
@@ -0,0 +1,7 @@
+import type { Policy } from '#shared/server/handler'
+import type { SignupBrowserEvent } from '#shared/signup-analytics'
+import { authenticated } from './authenticated'
+
+/** Anonymous browsers may record clicks, but cannot claim account setup outcomes. */
+export const signupEventAllowed: Policy = context =>
+ context.body.stage === 'oauth' || authenticated(context)
diff --git a/shared/checkin-external.ts b/shared/checkin-external.ts
index efedf2fd..456482ad 100644
--- a/shared/checkin-external.ts
+++ b/shared/checkin-external.ts
@@ -17,7 +17,7 @@ Interpret this site evidence:
- \`skilld.baseline\` result evidence records the window against the daily cadence. \`stale\` means the saved baseline is old; report the \`gapHours\` figure and compare rates per day, never as overnight step changes. The shared CLI rejects invalid or future state before collecting evidence. Repair the state or supply an explicit \`--since\` boundary.
- Use failed-job \`first_failed_at\` and \`last_failed_at\`, plus \`d1.recentJobBatches\` and \`d1.registryMaintenance\`, to distinguish an active incident from a recovered burst. Do not call a window clean because the latest batch passed, or active because an older batch failed.
- \`d1.inventory.broken_repos\` is known cumulative inventory. Review \`d1.pipeline.newly_broken_repos_total\`, but gate health on \`d1.pipeline.newly_broken_repos_impacted\`. A source removal is impacting when it still backs a skill or appears in a star, subscription, or collection.
- - \`skilld.analytics\` holds command copies from Analytics Engine, which is the activation signal. Report \`commandCopies.run\` and \`commandCopies.install\` apart: a run copy reads a Skill once, an install copy keeps it. Analytics Engine samples, so treat a small day as a range, not an exact count. \`signup.stages\` counts sign-in and setup events by entry and outcome. These are attempts, not unique users or a joined conversion funnel. Report failures and email opt-outs separately. Missing events cannot prove abandonment.
+ - \`skilld.analytics\` holds command copies from Analytics Engine, which is the activation signal. Report \`commandCopies.run\` and \`commandCopies.install\` apart: a run copy reads a Skill once, an install copy keeps it. Analytics Engine samples, so treat a small day as a range, not an exact count. \`signup.stages\` counts sign-in and setup events by entry and outcome. OAuth \`clicked\` counts browser interactions. OAuth \`started\` counts server requests, including crawlers and direct requests. Neither proves a human conversion. These are attempts, not unique users or a joined conversion funnel. Report failures and email opt-outs separately. Missing events cannot prove abandonment.
- \`skilld.signing-key\` Warn or Fail means Artifact signing ends soon. Quote \`signingEndsAt\` and \`daysLeft\`, and point to \`docs/runbooks/signing-key-rotation.md\`. After that date every \`skilld run\` fails.
- AI cost is only the recorded batch estimate. Unmeasured services are unknown, not $0.`,
},
diff --git a/shared/signup-analytics.ts b/shared/signup-analytics.ts
index bfd1b3c1..19dd747c 100644
--- a/shared/signup-analytics.ts
+++ b/shared/signup-analytics.ts
@@ -2,6 +2,7 @@ import type { AnalyticsDataPoint } from './analytics'
import { z } from 'zod'
const browserStages = [
+ z.object({ stage: z.literal('oauth'), entry: z.enum(['direct', 'return-page', 'cli', 'like-skill', 'watch-skill', 'watch-collection']), outcome: z.literal('clicked') }).strict(),
z.object({ stage: z.literal('discover'), entry: z.literal('onboarding'), outcome: z.enum(['viewed', 'imported', 'import-failed', 'continued', 'skipped', 'watch-failed']) }).strict(),
z.object({ stage: z.literal('email'), entry: z.enum(['onboarding', 'dashboard']), outcome: z.enum(['viewed', 'failed', 'completion-failed']) }).strict(),
z.object({ stage: z.literal('email'), entry: z.enum(['onboarding', 'dashboard']), outcome: z.literal('saved'), choice: z.enum(['none', 'weekly', 'monthly', 'both']) }).strict(),
@@ -24,6 +25,20 @@ export function signupEntry(action: string, returnTo: string): SignupEntry {
return returnTo ? 'return-page' : 'direct'
}
+/** Classify a clicked sign-in link without recording its path or parameters. */
+export function signupClickEvent(href: string, origin: string): Extract | null {
+ if (!URL.canParse(href, origin))
+ return null
+ const url = new URL(href, origin)
+ if (url.origin !== origin || url.pathname !== '/auth/github')
+ return null
+ return {
+ stage: 'oauth',
+ outcome: 'clicked',
+ entry: signupEntry(url.searchParams.get('action') ?? '', url.searchParams.get('return_to') ?? ''),
+ }
+}
+
export function signupEmailChoice(weekly: boolean, monthly: boolean): 'none' | 'weekly' | 'monthly' | 'both' {
return weekly ? (monthly ? 'both' : 'weekly') : (monthly ? 'monthly' : 'none')
}
diff --git a/test/unit/signup-analytics.test.ts b/test/unit/signup-analytics.test.ts
index a2a38965..12df1f59 100644
--- a/test/unit/signup-analytics.test.ts
+++ b/test/unit/signup-analytics.test.ts
@@ -1,6 +1,23 @@
import { summarizeSignupEvents } from '../../checks/_helpers/signup-analytics'
import { recordSignupEvent } from '../../layers/identity/server/utils/signup-analytics'
-import { SignupBrowserEvent, signupDataPoint, signupEmailChoice, signupEntry } from '../../shared/signup-analytics'
+import { SignupBrowserEvent, signupClickEvent, signupDataPoint, signupEmailChoice, signupEntry } from '../../shared/signup-analytics'
+
+it('records a GitHub sign-in click using only fixed labels', () => {
+ expect(signupClickEvent('/auth/github?return_to=%2Fgh%2Fowner%2Frepo&action=like-skill', 'https://skilld.dev')).toEqual({
+ stage: 'oauth',
+ outcome: 'clicked',
+ entry: 'like-skill',
+ })
+ expect(signupClickEvent('/auth/github?return_to=%2Fcli%2Fauthorize%3Fuser_code%3DSECRET', 'https://skilld.dev')).toEqual({
+ stage: 'oauth',
+ outcome: 'clicked',
+ entry: 'cli',
+ })
+})
+
+it.each(['/login', 'https://other.example/auth/github', 'javascript:alert(1)', '/auth/github/callback', 'http://['])('ignores links outside the sign-in action: %s', (href) => {
+ expect(signupClickEvent(href, 'https://skilld.dev')).toBeNull()
+})
it('classifies CLI sign-in without storing its authorization parameters', () => {
expect(signupEntry('', '/cli/authorize?user_code=SECRET')).toBe('cli')
diff --git a/test/unit/signup-event-endpoint.test.ts b/test/unit/signup-event-endpoint.test.ts
index bdc4e0b4..829b37bb 100644
--- a/test/unit/signup-event-endpoint.test.ts
+++ b/test/unit/signup-event-endpoint.test.ts
@@ -29,6 +29,16 @@ it('refuses counts from a signed-out request', async () => {
expect(mocks.write).not.toHaveBeenCalled()
})
+it('accepts an anonymous sign-in click without granting access to setup events', async () => {
+ await expect(request({ stage: 'oauth', entry: 'like-skill', outcome: 'clicked' }, false)).resolves.toEqual({ ok: true })
+ expect(mocks.write).toHaveBeenCalledWith({ blobs: ['oauth', '', 'signup', 'like-skill', '', 'clicked', ''], doubles: [0, 0, 1], indexes: ['signup:oauth'] })
+})
+
+it('refuses browser claims about server sign-in outcomes', async () => {
+ await expect(request({ stage: 'oauth', entry: 'direct', outcome: 'succeeded' }, false)).rejects.toMatchObject({ statusCode: 400 })
+ expect(mocks.write).not.toHaveBeenCalled()
+})
+
it('refuses an event carrying an account identifier', async () => {
await expect(request({ stage: 'email', entry: 'onboarding', outcome: 'saved', choice: 'none', userId: 42 }, true)).rejects.toMatchObject({ statusCode: 400 })
expect(mocks.write).not.toHaveBeenCalled()
From 21fd675fb4b9ef77741c84b91c55a2698c7ff73f Mon Sep 17 00:00:00 2001
From: Harlan Wilton
Date: Mon, 12 Oct 2026 12:22:49 +1100
Subject: [PATCH 02/14] fix(discovery): shorten demo prompts and expose run
actions
---
app/components/DemoStage.vue | 19 +++++++++++-----
app/components/_DemoActions.vue | 1 -
app/components/_DemoFooter.vue | 1 +
.../registry/app/components/SkillDetail.vue | 9 ++++++++
layers/registry/app/components/_SkillDemo.vue | 22 ++++++++++++++-----
test/a11y.nuxt.spec.ts | 9 ++++++++
6 files changed, 49 insertions(+), 12 deletions(-)
diff --git a/app/components/DemoStage.vue b/app/components/DemoStage.vue
index 0eec4940..53fa6527 100644
--- a/app/components/DemoStage.vue
+++ b/app/components/DemoStage.vue
@@ -10,8 +10,8 @@ import DemoWriting from './home-demos/_DemoWriting.vue'
/**
* One demo on show: how it was recorded, the output in a window one screen
* tall (a page that scrolls inside it, or a film that plays while on screen),
- * and the Skill's row with its run command. `showPrompt` adds the prompt in
- * full, for a list that names the demos without their prompts. `live` puts a
+ * and the Skill's row with its run command. `showPrompt` adds a disclosure
+ * for the recorded task. `live` puts a
* page demo's real output in the window, in a frame with no same-origin
* access; the route serves it under a CSP sandbox too. `opens` picks where
* "Open the demo" goes: the demo's own page, or, on that page, the Skill
@@ -56,9 +56,14 @@ const liveUrl = computed(() => live && !demo.video && !demo.writing ? demo.liveU
-
- Prompt{{ demo.prompt }}
-
+
+
+ Prompt
+
+
+ {{ demo.prompt }}
+
+
@@ -225,6 +230,10 @@ const liveUrl = computed(() => live && !demo.video && !demo.writing ? demo.liveU
outline-offset: 2px;
}
+.demo-stage__task--full summary {
+ padding-inline-end: 0;
+}
+
/* The live page scrolls inside its own frame. */
.demo-stage__window[data-live] {
overflow: hidden;
diff --git a/app/components/_DemoActions.vue b/app/components/_DemoActions.vue
index 54d1e1d2..d5983e36 100644
--- a/app/components/_DemoActions.vue
+++ b/app/components/_DemoActions.vue
@@ -63,5 +63,4 @@ async function share() {
diff --git a/app/components/_DemoFooter.vue b/app/components/_DemoFooter.vue
index 88d97fb0..ba6dee65 100644
--- a/app/components/_DemoFooter.vue
+++ b/app/components/_DemoFooter.vue
@@ -56,6 +56,7 @@ const emit = defineEmits<{ action: [action: { event: 'share' } | { event: 'copy'
}
.demo-footer__open:hover { color: var(--ui-text-highlighted); }
@media (max-width: 39.99rem) {
+ .demo-footer { grid-template-columns: minmax(0, 1fr); }
.demo-footer :deep(.skill-card--row) { padding-block: 0.5rem; }
.demo-footer :deep(.skill-card__row-end) { display: none; }
}
diff --git a/layers/registry/app/components/SkillDetail.vue b/layers/registry/app/components/SkillDetail.vue
index cfb5d56f..45f7942f 100644
--- a/layers/registry/app/components/SkillDetail.vue
+++ b/layers/registry/app/components/SkillDetail.vue
@@ -1544,6 +1544,15 @@ useHead(computed(() => ({
class="ml-auto hidden shrink-0 lg:flex"
/>
+
diff --git a/layers/registry/app/components/_SkillDemo.vue b/layers/registry/app/components/_SkillDemo.vue
index 27e935cb..b2b1538b 100644
--- a/layers/registry/app/components/_SkillDemo.vue
+++ b/layers/registry/app/components/_SkillDemo.vue
@@ -46,12 +46,17 @@ const DESKTOP_WINDOW_MEDIA = '(min-width: 48rem)'
-
- Prompt{{ demo.prompt }}
-
-
- Folder{{ demo.setup }}
-
+
+
+ Prompt
+
+
+ {{ demo.prompt }}
+
+
+ Folder{{ demo.setup }}
+
+
@@ -162,6 +167,11 @@ const DESKTOP_WINDOW_MEDIA = '(min-width: 48rem)'
background: var(--ui-bg-muted);
}
+.skill-demo__task summary:focus-visible {
+ outline: 2px solid var(--ui-border-accented);
+ outline-offset: 2px;
+}
+
.skill-demo__window {
overflow-x: hidden;
overflow-y: auto;
diff --git a/test/a11y.nuxt.spec.ts b/test/a11y.nuxt.spec.ts
index c734d62f..ba90b35f 100644
--- a/test/a11y.nuxt.spec.ts
+++ b/test/a11y.nuxt.spec.ts
@@ -797,6 +797,15 @@ describe('accessibility: components', () => {
expect(container.textContent).toContain('Recorded on an older version of this Skill.')
expect(container.querySelector('a[href="/skills/demos/anthropics/skills/frontend-design"]')?.textContent).toContain('All demos')
+ const prompt = wrapper.get('details')
+ expect(prompt.element.open).toBe(false)
+ expect(prompt.get('summary').text()).toBe('Prompt')
+ prompt.element.open = true
+ await nextTick()
+ expect(prompt.text()).toContain('Build a landing page for Tidepool. Save it as index.html.')
+ const expandedResults = await runAxe(container)
+ expect(expandedResults.violations, formatViolations(expandedResults)).toHaveLength(0)
+
// A flagged Skill is off the board, and its demo page answers 404.
await wrapper.setProps({ onBoard: false })
expect(container.querySelector('a[href^="/skills/demos/"]')).toBeNull()
From c1abb644b308d7563a4f19a565aa527f2082a5eb Mon Sep 17 00:00:00 2001
From: Harlan Wilton
Date: Mon, 12 Oct 2026 12:24:29 +1100
Subject: [PATCH 03/14] fix(auth): show account benefits and replay anonymous
likes
---
layers/identity/app/components/LikeButton.vue | 11 ++++-
.../app/components/WatchCollectionButton.vue | 8 +--
layers/identity/app/pages/login.vue | 49 ++++++++++++++++++-
layers/identity/server/utils/watch-actions.ts | 19 ++++++-
test/unit/like-button.nuxt.test.ts | 18 ++++++-
test/unit/like-oauth-replay.test.ts | 19 ++++++-
test/unit/login-benefits.nuxt.test.ts | 27 ++++++++++
7 files changed, 137 insertions(+), 14 deletions(-)
create mode 100644 test/unit/login-benefits.nuxt.test.ts
diff --git a/layers/identity/app/components/LikeButton.vue b/layers/identity/app/components/LikeButton.vue
index adb01ded..498b4f59 100644
--- a/layers/identity/app/components/LikeButton.vue
+++ b/layers/identity/app/components/LikeButton.vue
@@ -8,7 +8,7 @@ const { owner, repo, name, count = 0, variant = 'detail' } = defineProps<{
}>()
const route = useRoute()
-const { state, loginUrl } = useAuth()
+const { state } = useAuth()
const { isLiked, isPending, likeCount, observeLikeCount, ensureLoaded, toggle } = useLikes()
const skillRef = computed(() => ({ owner, repo, name }))
@@ -29,7 +29,14 @@ const displayCount = computed(() => likeCount(skillRef.value) ?? baseline.value)
// Anonymous clicks bounce through OAuth carrying the intent, and the callback
// replays it via watch-actions.ts, so the user lands back on a liked skill.
-const anonHref = computed(() => loginUrl({ returnTo: route.fullPath, action: 'like-skill' }))
+const anonHref = computed(() => {
+ const skillPath = `/gh/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(name)}`
+ const repoPath = `/gh/${owner}/${repo}`
+ const returnTo = variant === 'detail' && (route.path === repoPath || route.path === skillPath)
+ ? route.fullPath
+ : skillPath
+ return `/login?${new URLSearchParams({ return_to: returnTo, action: 'like-skill' })}`
+})
const NUDGE_KEY = 'skilld:like-digest-nudge-dismissed'
const nudgeDismissed = ref(true)
diff --git a/layers/identity/app/components/WatchCollectionButton.vue b/layers/identity/app/components/WatchCollectionButton.vue
index d483af1d..f5ce3a42 100644
--- a/layers/identity/app/components/WatchCollectionButton.vue
+++ b/layers/identity/app/components/WatchCollectionButton.vue
@@ -1,13 +1,13 @@
@@ -32,7 +41,40 @@ useSeoMeta({
Sign in to skilld
- Like skills, build collections, and get the weekly: changes to your skills, plus what devs are talking about. We ask for your public profile and email address.
+ Use your GitHub account. New here? The same step creates your account.
+
+
+
+
-
+ Your skills
+
+ -
+ Like Skills to find them again in your account.
+
+
+
+
-
+ Watch for changes
+
+ -
+ Watch the Repositories you use and see what changed.
+
+
+
+
-
+ Email updates
+
+ -
+ Choose the weekly, monthly watched changes, or neither.
+
+
+
+
+ Preview the weekly
+
+
+
+ {{ actionMessage }}
+
+ We ask for your public profile and email address.
+
{
wrapper.unmount()
})
- it('sends an anonymous visitor through OAuth carrying the like intent and return path', async () => {
+ it('shows account benefits before OAuth and carries the selected Skill from a listing', async () => {
loggedIn.value = false
const wrapper = await mountSuspended(LikeButton, { props: { ...skill, count: 2 } })
await flushPromises()
@@ -151,13 +151,27 @@ describe('likeButton toggle semantics', () => {
const link = wrapper.get('a')
const href = link.attributes('href')!
expect(wrapper.find('button').exists()).toBe(false)
+ expect(new URL(href, 'https://skilld.dev').pathname).toBe('/login')
expect(new URL(href, 'https://skilld.dev').searchParams.get('action')).toBe('like-skill')
- expect(new URL(href, 'https://skilld.dev').searchParams.get('return_to')).toBe('/')
+ expect(new URL(href, 'https://skilld.dev').searchParams.get('return_to')).toBe('/gh/antfu/skills/nuxt')
expect(link.attributes('aria-label')).toBe('Like nuxt')
wrapper.unmount()
})
+ it('preserves a single Skill canonical path and query through account entry', async () => {
+ loggedIn.value = false
+ const wrapper = await mountSuspended(LikeButton, {
+ props: skill,
+ route: '/gh/antfu/skills?from=search#run',
+ })
+ await flushPromises()
+ const href = new URL(wrapper.get('a').attributes('href')!, 'https://skilld.dev')
+ expect(href.pathname).toBe('/login')
+ expect(href.searchParams.get('return_to')).toBe('/gh/antfu/skills?from=search#run')
+ wrapper.unmount()
+ })
+
it('holds a disabled heart with the count until the session is known, then offers the like', async () => {
sessionKnown.value = false
likedItems = [{ ...skill, likeCount: 5 }]
diff --git a/test/unit/like-oauth-replay.test.ts b/test/unit/like-oauth-replay.test.ts
index ead3bc47..3135e87c 100644
--- a/test/unit/like-oauth-replay.test.ts
+++ b/test/unit/like-oauth-replay.test.ts
@@ -2,6 +2,12 @@ import type { H3Event } from 'h3'
import Database from 'better-sqlite3'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+const fetchRoute = vi.hoisted(() => vi.fn())
+vi.mock('ofetch', async original => ({
+ ...await original(),
+ $fetch: Object.assign(fetchRoute, { create: () => fetchRoute }),
+}))
+
/**
* An anonymous heart click bounces through GitHub OAuth carrying ?action=like-skill.
* If the callback fails to replay it the user lands back on the skill with an
@@ -16,6 +22,7 @@ describe('post-OAuth like replay', () => {
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-10T09:00:00.000Z'))
+ fetchRoute.mockReset().mockResolvedValue({ target: { _tag: 'repo' } })
vi.stubGlobal('createError', (input: Record) => Object.assign(new Error(String(input.message)), input))
sqlite = new Database(':memory:')
@@ -64,13 +71,21 @@ describe('post-OAuth like replay', () => {
expect(likes()).toEqual([{ owner: 'nuxt', repo: 'ui', name: 'motion' }])
})
- it('does nothing when the path has no skill name to like', async () => {
- await handleWatchAction(event(), 1, 'like-skill', '/gh/nuxt/ui')
+ it('refuses to like an ambiguous repository path', async () => {
+ await expect(handleWatchAction(event(), 1, 'like-skill', '/gh/nuxt/ui')).rejects.toMatchObject({ statusCode: 400 })
expect(likes()).toEqual([])
expect(subs()).toEqual([])
})
+ it('replays a like on a single Skill canonical repository path', async () => {
+ fetchRoute.mockResolvedValue({ target: { _tag: 'skill', name: 'design-tokens' } })
+ await handleWatchAction(event(), 1, 'like-skill', '/gh/nuxt/ui?from=search#run')
+ expect(likes()).toEqual([{ owner: 'nuxt', repo: 'ui', name: 'design-tokens' }])
+ expect(subs()).toEqual([{ owner: 'nuxt', repo: 'ui', source: 'like' }])
+ expect(fetchRoute).toHaveBeenCalledWith('/api/repos/nuxt/ui/route-target')
+ })
+
it('still supports watch-skill from a repo path with no skill segment', async () => {
await handleWatchAction(event(), 1, 'watch-skill', '/gh/nuxt/ui')
diff --git a/test/unit/login-benefits.nuxt.test.ts b/test/unit/login-benefits.nuxt.test.ts
new file mode 100644
index 00000000..e033e3b3
--- /dev/null
+++ b/test/unit/login-benefits.nuxt.test.ts
@@ -0,0 +1,27 @@
+import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+import Login from '../../layers/identity/app/pages/login.vue'
+
+mockNuxtImport('useUserSession', () => () => ({ loggedIn: ref(false), ready: ref(true), fetch: async () => {} }))
+mockNuxtImport('useAuth', () => () => ({
+ loginUrl: ({ returnTo, action }: { returnTo: string, action: string }) => `/auth/github?${new URLSearchParams({ return_to: returnTo, action })}`,
+}))
+
+describe('account entry', () => {
+ it('shows the benefits before GitHub and preserves a failed like intent for retry', async () => {
+ const wrapper = await mountSuspended(Login, {
+ route: '/login?return_to=%2Fgh%2Fnuxt%2Fui%3Ffrom%3Dsearch&action=like-skill&error=oauth',
+ })
+ expect(wrapper.text()).toContain('Sign in to like this Skill.')
+ expect(wrapper.text()).toContain('Choose the weekly, monthly watched changes, or neither.')
+ expect(wrapper.get('a[href="/weekly/preview"]').text()).toContain('Preview the weekly')
+ const href = wrapper.findAll('a').find(link => link.text().includes('Continue with GitHub'))!.attributes('href')!
+ const url = new URL(href, 'https://skilld.dev')
+ expect(url.pathname).toBe('/auth/github')
+ expect(url.searchParams.get('return_to')).toBe('/gh/nuxt/ui?from=search')
+ expect(url.searchParams.get('action')).toBe('like-skill')
+ expect(wrapper.get('[role="alert"]').text()).toBe('Couldn\'t complete sign-in. Try again.')
+ wrapper.unmount()
+ })
+})
From 57fd6dfb988c7858d19f364975cae6114eba55c7 Mon Sep 17 00:00:00 2001
From: Harlan Wilton
Date: Mon, 12 Oct 2026 12:28:14 +1100
Subject: [PATCH 04/14] fix(identity): preserve star imports and guide account
activation
---
layers/identity/app/pages/me/index.vue | 24 +++-
.../app/pages/onboarding/discover.vue | 28 +++--
.../identity/app/utils/sync-starred-repos.ts | 7 +-
layers/identity/server/api/me/starred.get.ts | 8 +-
.../identity/server/api/me/stars/sync.post.ts | 5 +-
.../api/v1/account/stars/import.post.ts | 2 +
layers/identity/server/schemas/stars.ts | 1 +
layers/identity/server/utils/starred-repos.ts | 117 ++++++++++++------
migrations/0154_user_starred_import.sql | 15 +++
test/unit/api-v1-account.test.ts | 2 +-
test/unit/me-watchlist.nuxt.test.ts | 28 +++--
test/unit/starred-import-coverage.test.ts | 96 ++++++++++++++
test/unit/sync-starred-repos-client.test.ts | 23 ++++
13 files changed, 283 insertions(+), 73 deletions(-)
create mode 100644 migrations/0154_user_starred_import.sql
create mode 100644 test/unit/starred-import-coverage.test.ts
diff --git a/layers/identity/app/pages/me/index.vue b/layers/identity/app/pages/me/index.vue
index 78b3a4b9..9ac24388 100644
--- a/layers/identity/app/pages/me/index.vue
+++ b/layers/identity/app/pages/me/index.vue
@@ -117,7 +117,9 @@ const syncMutation = useNuxtMutation({
onMutate: () => actionFailed.clear('sync your starred repos'),
})
async function sync() {
- await syncMutation.mutateSafe()
+ const imported = await syncMutation.mutateSafe()
+ if (imported._tag === 'ok')
+ await navigateTo('/onboarding/discover')
}
const unwatchingRepository = ref()
@@ -161,6 +163,7 @@ async function saveEmail() {
const likedSkills = computed(() => likes.value?.items ?? [])
const likesUnavailable = computed(() => !!likesError.value && !likes.value)
const likesLoading = computed(() => likesStatus.value === 'pending' && !likes.value)
+const monthlyDeliveryActive = computed(() => !!me.value?.email_opt_in && !!(me.value?.digest_email ?? me.value?.email ?? '').trim())
const watchedRepositoryCount = computed(() => subs.value?.items.length ?? 0)
function sourceDescription(source: string): string {
@@ -375,12 +378,21 @@ async function deleteAccount() {
- Add your first skill
+ {{ watchedRepositoryCount ? 'Your watched Repositories are ready' : 'Find Skills from your GitHub stars' }}
- Like any skill to keep it here. Turn on the monthly digest to receive its changes.
+
+ {{ watchedRepositoryCount }} watched {{ watchedRepositoryCount === 1 ? 'Repository' : 'Repositories' }}. Like a Skill to keep it here.
+
+
+ Browse curated Skills, then like one to keep it in your account.
+
-
+
+
+
+
+
@@ -409,7 +421,7 @@ async function deleteAccount() {
Changes to liked Skills and watched Repositories on the first day of each month.
-
+
@@ -556,7 +568,7 @@ async function deleteAccount() {
{{ watchedRepositoryCount }} {{ watchedRepositoryCount === 1 ? 'repository supports' : 'repositories support' }} your skill updates.
- Last GitHub import: {{ fmtDate(me?.stars_synced_at) }}
+ Last complete GitHub import: {{ fmtDate(me?.stars_synced_at) }}
('/api/me/starred')
+const { data, refresh, status, error } = await useFetch<{ items: StarredRepo[], syncedAt: number | null, partial: boolean }>('/api/me/starred')
const skillItems = computed(() => (data.value?.items ?? []).filter(r => r.hasSkill))
const totalSkills = computed(() => skillItems.value.reduce((n, r) => n + r.skills.length, 0))
@@ -68,7 +68,7 @@ async function syncStars() {
recordSignup({ stage: 'discover', outcome: 'import-failed', entry: 'onboarding' })
syncError.value = (e as { statusMessage?: string, message?: string }).statusMessage
?? (e as Error).message
- ?? 'Sync failed'
+ ?? 'Could not import GitHub stars. Try again.'
}
finally {
syncing.value = false
@@ -77,7 +77,7 @@ async function syncStars() {
onMounted(() => {
recordSignup({ stage: 'discover', outcome: 'viewed', entry: 'onboarding' })
- if (data.value && !data.value.syncedAt) {
+ if (data.value && !data.value.syncedAt && !data.value.partial) {
void syncStars()
return
}
@@ -121,7 +121,7 @@ useSeoMeta({ title: 'Discover skills', robots: 'noindex' })
Watch what you've already starred
- 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.
+ We check up to 1,000 starred GitHub Repositories against the registry. Choose which to watch. Email updates are optional.
- Page {{ syncProgress.page || '…' }} · {{ syncProgress.total }} repos cached · {{ syncProgress.matched }} match the registry
+ Page {{ syncProgress.page || '…' }} · {{ syncProgress.total }} Repositories checked · {{ syncProgress.matched }} match the registry
Last GitHub import
@@ -149,6 +149,10 @@ useSeoMeta({ title: 'Discover skills', robots: 'noindex' })
+
+ Checked the first 1,000 GitHub stars. Some starred Repositories were not checked.
+
+
{{ syncError }}
@@ -163,15 +167,19 @@ useSeoMeta({ title: 'Discover skills', robots: 'noindex' })
-
- No star import yet. Click Import stars above, or
+
+ Import your GitHub stars, or browse curated Skills.
- None of your starred repos with "skill" in the name are in the registry yet.
+ None of the checked Repositories have Skills in the registry yet.
-
+
+ Browse curated Skills, then like one to keep it in your account.
+
+
+
skip ahead
- .
+
diff --git a/layers/identity/app/utils/sync-starred-repos.ts b/layers/identity/app/utils/sync-starred-repos.ts
index 4bd7361c..a86ce941 100644
--- a/layers/identity/app/utils/sync-starred-repos.ts
+++ b/layers/identity/app/utils/sync-starred-repos.ts
@@ -1,4 +1,5 @@
export interface StarsSyncResponse {
+ importId?: string
ok: true
page: number
fetched: number
@@ -10,7 +11,7 @@ export interface StarsSyncResponse {
interface StarsSyncRequest {
method: 'POST'
- body: { page: number }
+ body: { page: number, importId?: string }
}
type StarsSyncFetch = (
@@ -22,11 +23,13 @@ export async function syncStarredRepos(
fetchPage: StarsSyncFetch,
onPage?: (response: StarsSyncResponse) => void,
): Promise {
+ let importId: string | undefined
for (let page = 1; page <= 10; page++) {
const response = await fetchPage('/api/me/stars/sync', {
method: 'POST',
- body: { page },
+ body: importId ? { page, importId } : { page },
})
+ importId = response.importId
onPage?.(response)
if (!response.hasMore)
return response
diff --git a/layers/identity/server/api/me/starred.get.ts b/layers/identity/server/api/me/starred.get.ts
index 7121934d..6dd1e378 100644
--- a/layers/identity/server/api/me/starred.get.ts
+++ b/layers/identity/server/api/me/starred.get.ts
@@ -8,6 +8,12 @@ export default defineApiHandler({
policy: [authenticated],
handler: async ({ event, platform }) => {
const u = await requireUserRow(event)
- return starredReposPresenter(await loadStarredRows(platform.db, u.id), u.stars_synced_at)
+ const state = await platform.db.prepare('SELECT active_partial FROM user_starred_import_state WHERE user_id = ?1')
+ .bind(u.id)
+ .first<{ active_partial: number }>()
+ return {
+ ...starredReposPresenter(await loadStarredRows(platform.db, u.id), u.stars_synced_at),
+ partial: !!state?.active_partial,
+ }
},
})
diff --git a/layers/identity/server/api/me/stars/sync.post.ts b/layers/identity/server/api/me/stars/sync.post.ts
index ab270d87..24906e4d 100644
--- a/layers/identity/server/api/me/stars/sync.post.ts
+++ b/layers/identity/server/api/me/stars/sync.post.ts
@@ -12,7 +12,9 @@ export default defineApiHandler({
const config = useRuntimeConfig(event)
const githubToken = await requireGithubUserToken(platform.db, u.id, config.tokenKey as string)
- const result = await importStarredPage({ db: platform.db, userId: u.id, githubToken, page: body.page })
+ const result = await importStarredPage({ db: platform.db, userId: u.id, githubToken, page: body.page, importId: body.importId })
+ if (result._tag === 'ImportRestartRequired')
+ throw createError({ statusCode: 409, message: 'Another star import started. Import your stars again.' })
if (result._tag === 'GithubSignInRequired')
throw createError({ statusCode: 401, statusMessage: 'GitHub sign-in required', message: 'GitHub rejected your access. Sign in with GitHub again.' })
if (result._tag === 'GithubFailed')
@@ -21,6 +23,7 @@ export default defineApiHandler({
return {
ok: true as const,
page: result.page,
+ importId: result.importId,
fetched: result.fetched,
total: result.total,
matched: result.matched,
diff --git a/layers/identity/server/api/v1/account/stars/import.post.ts b/layers/identity/server/api/v1/account/stars/import.post.ts
index 6d08eb8f..bda16cd4 100644
--- a/layers/identity/server/api/v1/account/stars/import.post.ts
+++ b/layers/identity/server/api/v1/account/stars/import.post.ts
@@ -10,6 +10,8 @@ export default defineApiOperation({
// Throws the 401 that asks for a new GitHub sign-in when no token is stored.
const githubToken = await requireGithubUserToken(platform.db, user.id, useRuntimeConfig(event).tokenKey as string)
const result = await importStarredPage({ db: platform.db, userId: user.id, githubToken, page: input.body.page })
+ if (result._tag === 'ImportRestartRequired')
+ throw createError({ statusCode: 400, message: 'Star import pages must run in order. Restart from page one.' })
if (result._tag === 'GithubSignInRequired')
return operationFailure('AUTH_REQUIRED', 'GitHub rejected skilld\'s access to your account. Sign in on skilld.dev again.')
// SERVICE_UNAVAILABLE is implicit, so no handler returns it as a value:
diff --git a/layers/identity/server/schemas/stars.ts b/layers/identity/server/schemas/stars.ts
index ed4dc7b9..80e14614 100644
--- a/layers/identity/server/schemas/stars.ts
+++ b/layers/identity/server/schemas/stars.ts
@@ -1,6 +1,7 @@
import { z } from 'zod'
export const StarsSyncQuery = z.object({
+ importId: z.string().uuid().optional(),
page: z.coerce.number().int().min(1).max(10).catch(1),
})
diff --git a/layers/identity/server/utils/starred-repos.ts b/layers/identity/server/utils/starred-repos.ts
index 87a881b0..05c2d7a8 100644
--- a/layers/identity/server/utils/starred-repos.ts
+++ b/layers/identity/server/utils/starred-repos.ts
@@ -1,9 +1,10 @@
///
+import { z } from 'zod'
+
/**
- * A person's imported GitHub stars. The import keeps only Repositories whose
- * name mentions a Skill, and records whether the registry holds Skills for
- * each one.
+ * A person's imported GitHub stars. The bounded import checks every Repository name
+ * against registry coverage.
*/
/** One row per Skill of a starred Repository, or one row with no Skill. */
@@ -35,10 +36,10 @@ export async function loadStarredRows(db: D1Database, userId: number): Promise number
}): Promise {
@@ -79,8 +82,14 @@ export async function importStarredPage(input: {
const fetchImpl = input.fetch ?? globalThis.fetch
const now = input.now ?? (() => Math.floor(Date.now() / 1000))
- if (page === 1)
- await db.prepare(`DELETE FROM user_starred_repos WHERE user_id = ?1`).bind(userId).run()
+ const state = page === 1
+ ? null
+ : await db.prepare(
+ 'SELECT import_id, next_page FROM user_starred_import_state WHERE user_id = ?1',
+ ).bind(userId).first<{ import_id: string, next_page: number }>()
+ if (page !== 1 && (!state || state.next_page !== page || (input.importId && input.importId !== state.import_id)))
+ return { _tag: 'ImportRestartRequired' }
+ const importId = page === 1 ? crypto.randomUUID() : state!.import_id
const res = await fetchImpl(`https://api.github.com/user/starred?per_page=${PER_PAGE}&page=${page}`, {
headers: {
@@ -91,43 +100,73 @@ export async function importStarredPage(input: {
})
if (!res.ok)
return res.status === 401 ? { _tag: 'GithubSignInRequired' } : { _tag: 'GithubFailed', status: res.status }
- const raw = await res.json() as GitHubStarred[]
- const items = raw.filter(it => /skill/i.test(it.repo.name))
+ const raw = githubStarredPageSchema.parse(await res.json())
+ const items = raw
- if (items.length) {
- const stmts = items.map((it) => {
- const owner = it.repo.owner.login
- const repo = it.repo.name
- const starredAt = Math.floor(new Date(it.starred_at).getTime() / 1000)
- return db.prepare(
- `INSERT OR REPLACE INTO user_starred_repos (user_id, owner, repo, starred_at, has_skill)
- VALUES (?1,
- COALESCE((SELECT owner FROM repos WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?2),
- COALESCE((SELECT repo FROM repos WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?3), ?4,
- (SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
- FROM skills WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE))`,
- ).bind(userId, owner, repo, starredAt)
- })
- const chunk = 50
- for (let i = 0; i < stmts.length; i += chunk)
- await db.batch(stmts.slice(i, i + chunk))
+ const guard = 'EXISTS (SELECT 1 FROM user_starred_import_state WHERE user_id = ?1 AND import_id = ?5 AND next_page = ?6)'
+ const statements: D1PreparedStatement[] = []
+ if (page === 1) {
+ statements.push(
+ db.prepare('DELETE FROM user_starred_import WHERE user_id = ?1').bind(userId),
+ db.prepare(`INSERT INTO user_starred_import_state (user_id, import_id, next_page) VALUES (?1, ?2, 1)
+ ON CONFLICT (user_id) DO UPDATE SET import_id = excluded.import_id, next_page = 1`).bind(userId, importId),
+ )
}
+ statements.push(...items.map((it) => {
+ const owner = it.repo.owner.login
+ const repo = it.repo.name
+ const starredAt = Math.floor(new Date(it.starred_at).getTime() / 1000)
+ return db.prepare(
+ `INSERT OR REPLACE INTO user_starred_import (user_id, owner, repo, starred_at, has_skill)
+ SELECT ?1,
+ COALESCE((SELECT owner FROM repos WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?2),
+ COALESCE((SELECT repo FROM repos WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?3), ?4,
+ (SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
+ FROM skills WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE)
+ WHERE ${guard}`,
+ ).bind(userId, owner, repo, starredAt, importId, page)
+ }))
- const hasMore = raw.length === PER_PAGE && page < MAX_STAR_PAGES
+ const link = res.headers.get('link')
+ const hasNextPage = link ? /rel="next"/.test(link) : raw.length === PER_PAGE
+ const hasMore = hasNextPage && page < MAX_STAR_PAGES
- let importedAt: number | null = null
+ // A full tenth page can have unseen stars. Publish its bounded coverage,
+ // but never set the timestamp that means a complete import.
+ const importedAt = !hasNextPage ? now() : null
+
+ // Every mutation and the page advance share one D1 transaction. A stale
+ // request cannot insert, publish, or clear another browser's generation.
+ const stateGuard = 'EXISTS (SELECT 1 FROM user_starred_import_state WHERE user_id = ?1 AND import_id = ?2 AND next_page = ?3)'
+ const countIndex = statements.length
+ statements.push(db.prepare(
+ 'SELECT COUNT(*) AS n, SUM(has_skill) AS matched FROM user_starred_import WHERE user_id = ?1',
+ ).bind(userId))
if (!hasMore) {
- importedAt = now()
- await db.prepare(`UPDATE users SET stars_synced_at = ?1 WHERE id = ?2`).bind(importedAt, userId).run()
+ statements.push(
+ db.prepare(`DELETE FROM user_starred_repos WHERE user_id = ?1 AND ${stateGuard}`).bind(userId, importId, page),
+ db.prepare(`INSERT INTO user_starred_repos (user_id, owner, repo, starred_at, has_skill)
+ SELECT user_id, owner, repo, starred_at, has_skill FROM user_starred_import
+ WHERE user_id = ?1 AND ${stateGuard}`).bind(userId, importId, page),
+ db.prepare(`UPDATE users SET stars_synced_at = ?4 WHERE id = ?1 AND ${stateGuard}`).bind(userId, importId, page, importedAt),
+ db.prepare(`DELETE FROM user_starred_import WHERE user_id = ?1 AND ${stateGuard}`).bind(userId, importId, page),
+ )
}
-
- const totalRow = await db.prepare(
- `SELECT COUNT(*) as n, SUM(has_skill) as matched FROM user_starred_repos WHERE user_id = ?1`,
- ).bind(userId).first<{ n: number, matched: number | null }>()
+ const advanceIndex = statements.length
+ statements.push(db.prepare(
+ `UPDATE user_starred_import_state SET next_page = ?4,
+ active_partial = COALESCE(?5, active_partial)
+ WHERE user_id = ?1 AND import_id = ?2 AND next_page = ?3`,
+ ).bind(userId, importId, page, page + 1, hasMore ? null : Number(hasNextPage)))
+ const results = await db.batch<{ n: number, matched: number | null }>(statements)
+ if (!results[advanceIndex]?.meta.changes)
+ return { _tag: 'ImportRestartRequired' }
+ const totalRow = results[countIndex]?.results?.[0]
return {
_tag: 'Imported',
page,
+ importId,
fetched: items.length,
total: totalRow?.n ?? 0,
matched: totalRow?.matched ?? 0,
diff --git a/migrations/0154_user_starred_import.sql b/migrations/0154_user_starred_import.sql
new file mode 100644
index 00000000..afe3977a
--- /dev/null
+++ b/migrations/0154_user_starred_import.sql
@@ -0,0 +1,15 @@
+-- Stage bounded GitHub star pages before replacing the last usable import.
+CREATE TABLE user_starred_import (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ owner TEXT NOT NULL,
+ repo TEXT NOT NULL,
+ starred_at INTEGER NOT NULL,
+ has_skill INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (user_id, owner, repo)
+);
+CREATE TABLE user_starred_import_state (
+ user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ import_id TEXT NOT NULL,
+ next_page INTEGER NOT NULL,
+ active_partial INTEGER NOT NULL DEFAULT 0
+);
diff --git a/test/unit/api-v1-account.test.ts b/test/unit/api-v1-account.test.ts
index 46988598..95f4e8ea 100644
--- a/test/unit/api-v1-account.test.ts
+++ b/test/unit/api-v1-account.test.ts
@@ -321,7 +321,7 @@ describe('presentStarsImport', () => {
[{ page: 3, hasMore: true, importedAt: null }, { nextPage: 4, importedAt: null }],
[{ page: 4, hasMore: false, importedAt: NOW_SEC }, { nextPage: null, importedAt: NOW.toISOString() }],
])('points at the next page until the import ends', (page, expected) => {
- const answer = presentStarsImport({ _tag: 'Imported', fetched: 2, total: 9, matched: 4, ...page })
+ const answer = presentStarsImport({ _tag: 'Imported', importId: 'fixture', fetched: 2, total: 9, matched: 4, ...page })
expect(starsV1.operations.import.response.body.producer.parse(answer)).toEqual({ page: page.page, imported: 9, withSkills: 4, ...expected })
})
diff --git a/test/unit/me-watchlist.nuxt.test.ts b/test/unit/me-watchlist.nuxt.test.ts
index 8158e8f1..b71e2356 100644
--- a/test/unit/me-watchlist.nuxt.test.ts
+++ b/test/unit/me-watchlist.nuxt.test.ts
@@ -50,6 +50,8 @@ let releaseDelete: (() => void) | undefined
let releaseEmailSave: (() => void) | undefined
const requestFetch = vi.hoisted(() => vi.fn())
+const navigate = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
+mockNuxtImport('navigateTo', () => navigate)
const executeRpc = vi.fn()
const queryRpc = vi.fn()
@@ -151,15 +153,9 @@ function buttonWithText(wrapper: Awaited>, text: st
return button
}
-function paragraphWithText(wrapper: Awaited>, text: string) {
- const paragraph = wrapper.findAll('p').find(candidate => candidate.text().includes(text))
- if (!paragraph)
- throw new Error(`Could not find paragraph: ${text}`)
- return paragraph
-}
-
describe('account skill watchlist', () => {
beforeEach(() => {
+ navigate.mockClear()
accountError.value = undefined
subscriptionsError.value = undefined
retryAccount.mockClear()
@@ -274,7 +270,7 @@ describe('account skill watchlist', () => {
await wrapper.get('button[aria-label="Remove nuxt from your skills"]').trigger('click')
await flushPromises()
- expect(wrapper.text()).toContain('Add your first skill')
+ expect(wrapper.text()).toContain('Find Skills from your GitHub stars')
const router = wrapper.vm.$router
await router.push('/?view=repositories')
await flushPromises()
@@ -296,15 +292,21 @@ describe('account skill watchlist', () => {
expect(disabledWhilePending).toBe(true)
})
- it('refreshes the visible account timestamp after syncing GitHub stars', async () => {
+ it('opens imported star results for watch selection after refreshing account data', async () => {
const wrapper = await mountPage('repositories')
- const before = paragraphWithText(wrapper, 'Last GitHub import:').text()
-
await buttonWithText(wrapper, 'Import stars again').trigger('click')
await flushPromises()
- const after = paragraphWithText(wrapper, 'Last GitHub import:').text()
+ expect(account.value?.stars_synced_at).toBe(newSyncTime)
+ expect(navigate).toHaveBeenCalledWith('/onboarding/discover')
+ })
- expect(after).not.toBe(before)
+ it('shows existing watches when no Skills are liked', async () => {
+ likes.value = { items: [] }
+ subscriptions.value = { items: [{ owner: 'nuxt', repo: 'ui', source: 'star-import' }] }
+ const wrapper = await mountPage()
+ expect(wrapper.text()).toContain('Your watched Repositories are ready')
+ expect(wrapper.text()).toContain('1 watched Repository.')
+ expect(wrapper.get('a[href="/me?view=repositories"]').text()).toContain('View watched Repositories')
})
it('shows a retry state when liked skills fail to load', async () => {
diff --git a/test/unit/starred-import-coverage.test.ts b/test/unit/starred-import-coverage.test.ts
new file mode 100644
index 00000000..e4e80bc8
--- /dev/null
+++ b/test/unit/starred-import-coverage.test.ts
@@ -0,0 +1,96 @@
+import type { SqliteD1 } from './helpers/d1-sqlite'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { importStarredPage, loadStarredRows, MAX_STAR_PAGES } from '../../layers/identity/server/utils/starred-repos'
+import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite'
+
+const star = (name: string) => ({ starred_at: '2026-10-05T00:00:00Z', repo: { name, owner: { login: 'nuxt' } } })
+
+describe('star import coverage', () => {
+ let d1: SqliteD1
+ beforeEach(() => {
+ d1 = createSqliteD1(allMigrations())
+ d1.raw.exec(`INSERT OR REPLACE INTO users (id, github_id, login, created_at, last_login_at, stars_synced_at) VALUES (1, 11, 'reader', 1, 1, 9);
+ INSERT INTO repos (owner, repo) VALUES ('nuxt', 'ui');
+ INSERT INTO skills (owner, repo, name, slug, display_name) VALUES ('nuxt', 'ui', 'ui', 'nuxt/ui/ui', 'UI');
+ INSERT INTO user_starred_repos VALUES (1, 'old', 'old-skills', 1, 0);
+ INSERT INTO skill_subscriptions (user_id, owner, repo, source, created_at) VALUES (1, 'nuxt', 'ui', 'manual', 1);`)
+ })
+ afterEach(() => d1.close())
+ const request = (page: number, response: Response) => importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page, now: () => 20, fetch: async () => response })
+
+ it('matches a registry Repository without skill in its name', async () => {
+ expect(await request(1, Response.json([star('ui')]))).toMatchObject({ matched: 1, total: 1, importedAt: 20 })
+ expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ repo: 'ui', has_skill: 1, watching: 1 }])
+ })
+ it('retains the last usable import when GitHub rejects page one', async () => {
+ expect(await request(1, new Response(null, { status: 401 }))).toEqual({ _tag: 'GithubSignInRequired' })
+ expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ owner: 'old', repo: 'old-skills' }])
+ })
+ it('retains the previous import when a later page fails', async () => {
+ await request(1, Response.json(Array.from({ length: 100 }, (_, i) => star(`new-${i}`))))
+ expect(await request(2, new Response(null, { status: 502 }))).toMatchObject({ _tag: 'GithubFailed' })
+ expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ owner: 'old', repo: 'old-skills' }])
+ expect(d1.raw.prepare('SELECT stars_synced_at FROM users WHERE id=1').get()).toMatchObject({ stars_synced_at: 9 })
+ })
+ it('replaces the import with an honest zero-match result without removing watches', async () => {
+ expect(await request(1, Response.json([star('unlisted')]))).toMatchObject({ total: 1, matched: 0, hasMore: false, importedAt: 20 })
+ expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ repo: 'unlisted', has_skill: 0 }])
+ expect(d1.raw.prepare('SELECT owner, repo FROM skill_subscriptions').all()).toEqual([{ owner: 'nuxt', repo: 'ui' }])
+ })
+ it('returns bounded partial coverage without claiming a complete import', async () => {
+ for (let page = 1; page < MAX_STAR_PAGES; page++)
+ await request(page, Response.json(Array.from({ length: 100 }, (_, i) => star(`p${page}-${i}`))))
+ expect(await request(MAX_STAR_PAGES, Response.json(Array.from({ length: 100 }, (_, i) => star(`p10-${i}`))))).toMatchObject({ total: 1000, hasMore: false, importedAt: null })
+ expect(d1.raw.prepare('SELECT stars_synced_at FROM users WHERE id=1').get()).toMatchObject({ stars_synced_at: null })
+ })
+})
+
+describe('isolated import generations', () => {
+ let d1: SqliteD1
+ beforeEach(() => {
+ d1 = createSqliteD1(allMigrations())
+ d1.raw.exec('INSERT OR REPLACE INTO users (id, github_id, login, created_at, last_login_at) VALUES (1, 11, \'reader\', 1, 1)')
+ })
+ afterEach(() => d1.close())
+ const start = () => importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page: 1, fetch: async () => Response.json(Array.from({ length: 100 }, (_, i) => star(`repo-${i}`))) })
+
+ it('rejects an old browser generation without publishing its rows', async () => {
+ const older = await start()
+ await start()
+ if (older._tag !== 'Imported')
+ throw new Error('Fixture import failed')
+ expect(await importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page: 2, importId: older.importId, fetch: async () => Response.json([star('obsolete')]) })).toEqual({ _tag: 'ImportRestartRequired' })
+ expect(await loadStarredRows(d1.db, 1)).toEqual([])
+ })
+ it('rejects an in-flight page if another browser restarts the import', async () => {
+ const older = await start()
+ if (older._tag !== 'Imported')
+ throw new Error('Fixture import failed')
+ let release!: (response: Response) => void
+ const pendingResponse = new Promise((resolve) => {
+ release = resolve
+ })
+ let entered!: () => void
+ const fetching = new Promise((resolve) => {
+ entered = resolve
+ })
+ const pending = importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page: 2, importId: older.importId, fetch: async () => {
+ entered()
+ return pendingResponse
+ } })
+ await fetching
+ await start()
+ release(Response.json([star('obsolete')]))
+ expect(await pending).toEqual({ _tag: 'ImportRestartRequired' })
+ expect(await loadStarredRows(d1.db, 1)).toEqual([])
+ })
+ it('rejects out-of-order pages without replacing an active import', async () => {
+ await start()
+ expect(await importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page: 3, fetch: async () => Response.json([]) })).toEqual({ _tag: 'ImportRestartRequired' })
+ expect(await loadStarredRows(d1.db, 1)).toEqual([])
+ })
+ it('uses GitHub pagination to complete an exactly full final page', async () => {
+ const response = await importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page: 1, now: () => 20, fetch: async () => Response.json(Array.from({ length: 100 }, (_, i) => star(`repo-${i}`)), { headers: { link: '; rel="first"' } }) })
+ expect(response).toMatchObject({ total: 100, hasMore: false, importedAt: 20 })
+ })
+})
diff --git a/test/unit/sync-starred-repos-client.test.ts b/test/unit/sync-starred-repos-client.test.ts
index 7e9972d7..b60c4899 100644
--- a/test/unit/sync-starred-repos-client.test.ts
+++ b/test/unit/sync-starred-repos-client.test.ts
@@ -53,3 +53,26 @@ describe('syncStarredRepos', () => {
expect(result).toMatchObject({ total: 0, matched: 0 })
})
})
+
+describe('bounded import results', () => {
+ it('returns partial coverage instead of claiming a complete import', async () => {
+ const fetchPage = vi.fn(async (_url: string, options: { body: { page: number } }) => ({
+ ok: true as const,
+ page: options.body.page,
+ fetched: 100,
+ total: options.body.page * 100,
+ matched: 2,
+ hasMore: options.body.page < 10,
+ syncedAt: null,
+ }))
+ expect(await syncStarredRepos(fetchPage)).toMatchObject({ page: 10, total: 1000, syncedAt: null })
+ expect(fetchPage).toHaveBeenCalledTimes(10)
+ })
+ it('carries the import generation into continuation requests', async () => {
+ const fetchPage = vi.fn()
+ .mockResolvedValueOnce({ ok: true, page: 1, fetched: 100, total: 100, matched: 2, hasMore: true, syncedAt: null, importId: 'generation-one' })
+ .mockResolvedValueOnce({ ok: true, page: 2, fetched: 1, total: 101, matched: 2, hasMore: false, syncedAt: 20, importId: 'generation-one' })
+ await syncStarredRepos(fetchPage)
+ expect(fetchPage).toHaveBeenLastCalledWith('/api/me/stars/sync', { method: 'POST', body: { page: 2, importId: 'generation-one' } })
+ })
+})
From c9a6de63a66ba01bda7f1b02a5e84fbef1578a53 Mon Sep 17 00:00:00 2001
From: Harlan Wilton
Date: Mon, 12 Oct 2026 12:40:30 +1100
Subject: [PATCH 05/14] fix(onboarding): retain matched repos and present
import coverage
---
COPY.md | 6 +++++-
layers/identity/app/pages/login.vue | 2 +-
layers/identity/server/api/me/starred.get.ts | 4 +++-
.../identity/server/api/me/stars/sync.post.ts | 13 +++---------
layers/identity/server/presenters/user.ts | 20 ++++++++++++++++---
layers/identity/server/utils/starred-repos.ts | 11 ++++++----
test/unit/starred-import-coverage.test.ts | 16 +++++++++++++++
7 files changed, 52 insertions(+), 20 deletions(-)
diff --git a/COPY.md b/COPY.md
index 39d3ff29..57902875 100644
--- a/COPY.md
+++ b/COPY.md
@@ -191,7 +191,11 @@ Use `7-day social mentions · {date}` as the footer. Format the UTC date as `2 O
| Login meta | Sign in with GitHub to keep liked Skills, watch for changes, and choose your emails. | Login page description. |
| Like needs a Skill | Open a Skill before liking it. | A Repository with several Skills cannot imply which Skill to like. |
| Star import | We check up to 1,000 starred GitHub Repositories against the registry. Choose which to watch. Email updates are optional. | Match registry coverage, regardless of Repository name. |
-| Star import empty | Find Skills from your GitHub stars · Import stars · None of the checked Repositories have Skills in the registry yet. · Browse curated Skills, then like one to keep it in your account. | Offer discovery when imported stars have no matching Skills. |
+| Star import empty | Find Skills from your GitHub stars · Import stars · None of the checked Repositories have Skills in the registry yet. · Browse curated Skills, then like one to keep it in your account. · Import your GitHub stars, or browse curated Skills. | Offer discovery when imported stars have no matching Skills. |
+| Star import progress | Page {page} · {total} Repositories checked · {matched} match the registry | Report checked Repositories and registry matches separately. |
+| Star import error | Could not import GitHub stars. Try again. | Use when an import fails without a specific error. |
+| Star import restart | Another star import started. Import your stars again. · Star import pages must run in order. Restart from page one. | Browser and CLI import recovery. |
+| Star import date | Last complete GitHub import: {date} | A bounded partial import has no completion date. |
| Star import partial | Checked the first 1,000 GitHub stars. Some starred Repositories were not checked. | The page limit leaves coverage partial. |
| Account without likes, with watches | Your watched Repositories are ready · {n} watched {Repository/Repositories}. Like a Skill to keep it here. · Browse skills · View watched Repositories | Show existing account value when the liked Skills view is empty. |
diff --git a/layers/identity/app/pages/login.vue b/layers/identity/app/pages/login.vue
index a33156dc..693cdb02 100644
--- a/layers/identity/app/pages/login.vue
+++ b/layers/identity/app/pages/login.vue
@@ -69,7 +69,7 @@ useSeoMeta({
-
+
Preview the weekly
diff --git a/layers/identity/server/api/me/starred.get.ts b/layers/identity/server/api/me/starred.get.ts
index 6dd1e378..d4582787 100644
--- a/layers/identity/server/api/me/starred.get.ts
+++ b/layers/identity/server/api/me/starred.get.ts
@@ -6,13 +6,15 @@ import { requireUserRow } from '../../utils/users'
export default defineApiHandler({
policy: [authenticated],
+ presenter: ({ rows, syncedAt, partial }) => starredReposPresenter(rows, syncedAt, partial),
handler: async ({ event, platform }) => {
const u = await requireUserRow(event)
const state = await platform.db.prepare('SELECT active_partial FROM user_starred_import_state WHERE user_id = ?1')
.bind(u.id)
.first<{ active_partial: number }>()
return {
- ...starredReposPresenter(await loadStarredRows(platform.db, u.id), u.stars_synced_at),
+ rows: await loadStarredRows(platform.db, u.id),
+ syncedAt: u.stars_synced_at,
partial: !!state?.active_partial,
}
},
diff --git a/layers/identity/server/api/me/stars/sync.post.ts b/layers/identity/server/api/me/stars/sync.post.ts
index 24906e4d..e438a9fb 100644
--- a/layers/identity/server/api/me/stars/sync.post.ts
+++ b/layers/identity/server/api/me/stars/sync.post.ts
@@ -1,5 +1,6 @@
import { defineApiHandler } from '#shared/server/handler'
import { authenticated } from '../../../policies/authenticated'
+import { starsImportPresenter } from '../../../presenters/user'
import { StarsSyncQuery } from '../../../schemas/stars'
import { importStarredPage } from '../../../utils/starred-repos'
import { requireGithubUserToken, requireUserRow } from '../../../utils/users'
@@ -7,6 +8,7 @@ import { requireGithubUserToken, requireUserRow } from '../../../utils/users'
export default defineApiHandler({
schema: StarsSyncQuery,
policy: [authenticated],
+ presenter: starsImportPresenter,
handler: async ({ event, body, platform }) => {
const u = await requireUserRow(event)
const config = useRuntimeConfig(event)
@@ -20,15 +22,6 @@ export default defineApiHandler({
if (result._tag === 'GithubFailed')
throw createError({ statusCode: 502, message: `GitHub error ${result.status}` })
- return {
- ok: true as const,
- page: result.page,
- importId: result.importId,
- fetched: result.fetched,
- total: result.total,
- matched: result.matched,
- hasMore: result.hasMore,
- syncedAt: result.importedAt,
- }
+ return result
},
})
diff --git a/layers/identity/server/presenters/user.ts b/layers/identity/server/presenters/user.ts
index 62b01cc6..68eee62a 100644
--- a/layers/identity/server/presenters/user.ts
+++ b/layers/identity/server/presenters/user.ts
@@ -1,4 +1,4 @@
-import type { StarredRow } from '../utils/starred-repos'
+import type { StarredRow, StarsImportPage } from '../utils/starred-repos'
import type { UserRow } from '../utils/users'
import { weeklyDeliveryActive } from '../utils/weekly-select'
@@ -14,7 +14,8 @@ export interface StarredRepoEntry {
export function starredReposPresenter(
rows: StarredRow[],
syncedAt: number | null,
-): { items: StarredRepoEntry[], syncedAt: number | null } {
+ partial = false,
+): { items: StarredRepoEntry[], syncedAt: number | null, partial: boolean } {
const byRepo = new Map()
for (const r of rows) {
const key = `${r.owner}/${r.repo}`
@@ -38,7 +39,20 @@ export function starredReposPresenter(
})
}
}
- return { items: [...byRepo.values()], syncedAt }
+ return { items: [...byRepo.values()], syncedAt, partial }
+}
+
+export function starsImportPresenter(result: Extract) {
+ return {
+ ok: true as const,
+ page: result.page,
+ importId: result.importId,
+ fetched: result.fetched,
+ total: result.total,
+ matched: result.matched,
+ hasMore: result.hasMore,
+ syncedAt: result.importedAt,
+ }
}
export function mePresenter(u: UserRow) {
diff --git a/layers/identity/server/utils/starred-repos.ts b/layers/identity/server/utils/starred-repos.ts
index 05c2d7a8..f5062f01 100644
--- a/layers/identity/server/utils/starred-repos.ts
+++ b/layers/identity/server/utils/starred-repos.ts
@@ -21,17 +21,20 @@ export interface StarredRow {
export async function loadStarredRows(db: D1Database, userId: number): Promise {
const res = await db.prepare(
- `SELECT COALESCE(r.owner, s.owner) AS owner, COALESCE(r.repo, s.repo) AS repo, s.starred_at,
+ `WITH bounded_stars AS (
+ SELECT * FROM user_starred_repos WHERE user_id = ?1
+ ORDER BY starred_at DESC LIMIT 1000
+ )
+ SELECT COALESCE(r.owner, s.owner) AS owner, COALESCE(r.repo, s.repo) AS repo, s.starred_at,
CASE WHEN sk.name IS NULL THEN 0 ELSE 1 END AS has_skill,
EXISTS (SELECT 1 FROM skill_subscriptions sub WHERE sub.user_id = s.user_id
AND sub.owner = s.owner COLLATE NOCASE AND sub.repo = s.repo COLLATE NOCASE) AS watching,
sk.name as skill_name, sk.display_name as skill_display, sk.slug as skill_slug
- FROM user_starred_repos s
+ FROM bounded_stars s
LEFT JOIN repos r ON r.owner = s.owner COLLATE NOCASE AND r.repo = s.repo COLLATE NOCASE
LEFT JOIN skills sk ON sk.owner = r.owner AND sk.repo = r.repo
WHERE s.user_id = ?1
- ORDER BY has_skill DESC, s.starred_at DESC, sk.name
- LIMIT 1000`,
+ ORDER BY has_skill DESC, s.starred_at DESC, sk.name`,
).bind(userId).all()
return res.results ?? []
}
diff --git a/test/unit/starred-import-coverage.test.ts b/test/unit/starred-import-coverage.test.ts
index e4e80bc8..b84ffd1f 100644
--- a/test/unit/starred-import-coverage.test.ts
+++ b/test/unit/starred-import-coverage.test.ts
@@ -1,5 +1,6 @@
import type { SqliteD1 } from './helpers/d1-sqlite'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { starredReposPresenter } from '../../layers/identity/server/presenters/user'
import { importStarredPage, loadStarredRows, MAX_STAR_PAGES } from '../../layers/identity/server/utils/starred-repos'
import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite'
@@ -18,6 +19,21 @@ describe('star import coverage', () => {
afterEach(() => d1.close())
const request = (page: number, response: Response) => importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page, now: () => 20, fetch: async () => response })
+ it('presents partial coverage without a completion date', async () => {
+ expect(starredReposPresenter(await loadStarredRows(d1.db, 1), null, true)).toMatchObject({ syncedAt: null, partial: true })
+ })
+
+ it('keeps other matched Repositories when one holds 1,000 Skills', async () => {
+ const skill = d1.raw.prepare('INSERT INTO skills (owner, repo, name, slug, display_name) VALUES (\'nuxt\', \'ui\', ?, ?, \'Skill\')')
+ for (let n = 1; n < 1000; n++)
+ skill.run(`skill-${n}`, `nuxt/ui/skill-${n}`)
+ d1.raw.exec(`INSERT INTO repos (owner, repo) VALUES ('nuxt', 'older');
+ INSERT INTO skills (owner, repo, name, slug, display_name) VALUES ('nuxt', 'older', 'older', 'nuxt/older/older', 'Older');
+ INSERT INTO user_starred_repos VALUES (1, 'nuxt', 'ui', 20, 1), (1, 'nuxt', 'older', 10, 1);`)
+ const rows = await loadStarredRows(d1.db, 1)
+ expect(rows.some(row => row.repo === 'older' && row.skill_name === 'older')).toBe(true)
+ })
+
it('matches a registry Repository without skill in its name', async () => {
expect(await request(1, Response.json([star('ui')]))).toMatchObject({ matched: 1, total: 1, importedAt: 20 })
expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ repo: 'ui', has_skill: 1, watching: 1 }])
From 17657ce49ab66c363d335265d9883975470b3415 Mon Sep 17 00:00:00 2001
From: Harlan GitHub Agent
Date: Mon, 12 Oct 2026 12:54:21 +1100
Subject: [PATCH 06/14] fix(identity): delete staged star imports with account
---
layers/identity/server/utils/account-deletion.ts | 2 ++
test/unit/account-deletion.nuxt.test.ts | 2 ++
2 files changed, 4 insertions(+)
diff --git a/layers/identity/server/utils/account-deletion.ts b/layers/identity/server/utils/account-deletion.ts
index 0ae49f13..3055f55b 100644
--- a/layers/identity/server/utils/account-deletion.ts
+++ b/layers/identity/server/utils/account-deletion.ts
@@ -18,6 +18,8 @@ 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 user_starred_import WHERE user_id = ?1`,
+ `DELETE FROM user_starred_import_state 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`,
diff --git a/test/unit/account-deletion.nuxt.test.ts b/test/unit/account-deletion.nuxt.test.ts
index 615cf3f1..71b17a69 100644
--- a/test/unit/account-deletion.nuxt.test.ts
+++ b/test/unit/account-deletion.nuxt.test.ts
@@ -91,6 +91,8 @@ function seedAccount(raw: DatabaseSync, account: Account, githubToken: string |
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)
+ run(`INSERT INTO user_starred_import (user_id, owner, repo, starred_at, has_skill) VALUES (?, 'acme', 'staged-skills', ?, 1)`, id, NOW)
+ run(`INSERT INTO user_starred_import_state (user_id, import_id, next_page, active_partial) VALUES (?, ?, 2, 1)`, id, `import-${login}`)
const collectionId = run(
`INSERT INTO collections_v2 (author_user_id, slug, name, created_at, updated_at) VALUES (?, 'stack', 'Stack', ?, ?)`,
id,
From 0181400d4d87e26d65b0e14dcdab112e34746c81 Mon Sep 17 00:00:00 2001
From: Harlan Wilton
Date: Mon, 12 Oct 2026 13:25:02 +1100
Subject: [PATCH 07/14] fix(analytics): prove and throttle anonymous signup
clicks
---
app/plugins/signup-clicks.client.ts | 34 +++++-
.../app/composables/useSignupEvents.ts | 8 +-
.../server/api/events/signup-proof.get.ts | 7 ++
.../identity/server/policies/signup-event.ts | 3 +-
.../server/utils/signup-click-proof.ts | 82 ++++++++++++++
migrations/0155_signup_click_proofs.sql | 6 ++
test/unit/signup-click-proof.test.ts | 101 ++++++++++++++++++
test/unit/signup-event-endpoint.test.ts | 6 +-
8 files changed, 241 insertions(+), 6 deletions(-)
create mode 100644 layers/identity/server/api/events/signup-proof.get.ts
create mode 100644 layers/identity/server/utils/signup-click-proof.ts
create mode 100644 migrations/0155_signup_click_proofs.sql
create mode 100644 test/unit/signup-click-proof.test.ts
diff --git a/app/plugins/signup-clicks.client.ts b/app/plugins/signup-clicks.client.ts
index f2d7b6a8..b9446581 100644
--- a/app/plugins/signup-clicks.client.ts
+++ b/app/plugins/signup-clicks.client.ts
@@ -1,8 +1,40 @@
import { useEventListener } from '@vueuse/core'
import { signupClickEvent } from '#shared/signup-analytics'
-export default defineNuxtPlugin(() => {
+export default defineNuxtPlugin((nuxtApp) => {
const recordSignup = useSignupEvents()
+ const route = useRoute()
+ const proof = useState('signup-click-proof', () => null)
+ let preparedPage = ''
+ let expiresAt = 0
+ async function prepareProof() {
+ const page = route.fullPath
+ if (preparedPage === page && expiresAt > Date.now())
+ return
+ if (![...document.querySelectorAll('a[href]')].some(link => signupClickEvent(link.href, window.location.origin)))
+ return
+ preparedPage = page
+ expiresAt = Date.now() + 240_000
+ proof.value = null
+ const result = await $fetch<{ proof: string | null }>('/api/events/signup-proof', { retry: false }).catch(() => {
+ console.warn('[signup-analytics] Could not prepare click proof')
+ return null
+ })
+ if (route.fullPath === page)
+ proof.value = result?.proof ?? null
+ if (!result?.proof)
+ expiresAt = 0
+ }
+ nuxtApp.hook('app:mounted', prepareProof)
+ nuxtApp.hook('page:finish', prepareProof)
+ watch(() => route.fullPath, () => {
+ proof.value = null
+ })
+ useEventListener(document, ['pointerover', 'focusin'], (event) => {
+ const link = event.target instanceof Element ? event.target.closest('a[href]') : null
+ if (link && signupClickEvent(link.href, window.location.origin))
+ void prepareProof()
+ })
useEventListener(document, 'click', (event) => {
// Page loads, prefetches, and programmatic DOM clicks are not click evidence.
if (!event.isTrusted || !(event.target instanceof Element))
diff --git a/layers/identity/app/composables/useSignupEvents.ts b/layers/identity/app/composables/useSignupEvents.ts
index 6a531589..b4beeaaa 100644
--- a/layers/identity/app/composables/useSignupEvents.ts
+++ b/layers/identity/app/composables/useSignupEvents.ts
@@ -2,11 +2,17 @@ import type { SignupBrowserEvent } from '#shared/signup-analytics'
export function useSignupEvents() {
const { loggedIn } = useUserSession()
+ const proof = useState('signup-click-proof', () => null)
return (body: SignupBrowserEvent): void => {
if (!loggedIn.value && body.stage !== 'oauth')
return
+ if (body.stage === 'oauth' && !proof.value)
+ return
+ const headers = body.stage === 'oauth' ? { 'x-signup-proof': proof.value! } : undefined
+ if (body.stage === 'oauth')
+ proof.value = null
// Keep the action independent of analytics and survive same-tab redirects.
- void $fetch('/api/events/signup', { method: 'POST', body, retry: false, keepalive: true }).catch(() => {
+ void $fetch('/api/events/signup', { method: 'POST', body, headers, retry: false, keepalive: true }).catch(() => {
console.warn('[signup-analytics] Could not record signup event')
})
}
diff --git a/layers/identity/server/api/events/signup-proof.get.ts b/layers/identity/server/api/events/signup-proof.get.ts
new file mode 100644
index 00000000..a0021d47
--- /dev/null
+++ b/layers/identity/server/api/events/signup-proof.get.ts
@@ -0,0 +1,7 @@
+import { defineApiHandler } from '#shared/server/handler'
+import { issueSignupClickProof } from '../../utils/signup-click-proof'
+
+export default defineApiHandler({
+ handler: ({ event }) => issueSignupClickProof(event),
+ presenter: proof => ({ proof }),
+})
diff --git a/layers/identity/server/policies/signup-event.ts b/layers/identity/server/policies/signup-event.ts
index 35e4793a..986938eb 100644
--- a/layers/identity/server/policies/signup-event.ts
+++ b/layers/identity/server/policies/signup-event.ts
@@ -1,7 +1,8 @@
import type { Policy } from '#shared/server/handler'
import type { SignupBrowserEvent } from '#shared/signup-analytics'
+import { consumeSignupClickProof } from '../utils/signup-click-proof'
import { authenticated } from './authenticated'
/** Anonymous browsers may record clicks, but cannot claim account setup outcomes. */
export const signupEventAllowed: Policy = context =>
- context.body.stage === 'oauth' || authenticated(context)
+ context.body.stage === 'oauth' ? consumeSignupClickProof(context.event) : authenticated(context)
diff --git a/layers/identity/server/utils/signup-click-proof.ts b/layers/identity/server/utils/signup-click-proof.ts
new file mode 100644
index 00000000..325fbd76
--- /dev/null
+++ b/layers/identity/server/utils/signup-click-proof.ts
@@ -0,0 +1,82 @@
+import type { H3Event } from 'h3'
+import { getHeader, getRequestURL, setHeader, useSession } from 'h3'
+import { useRuntimeConfig } from 'nitropack/runtime'
+
+const PROOF_SECONDS = 300
+
+interface ClickProof {
+ nonce: string
+ page: string
+ client: string
+ expiresAt: number
+}
+
+function browserPage(event: H3Event): string | null {
+ const referer = getHeader(event, 'referer')
+ if (!referer || !URL.canParse(referer))
+ return null
+ const page = new URL(referer)
+ const request = getRequestURL(event)
+ const origin = getHeader(event, 'origin')
+ if (page.origin !== request.origin || (origin && origin !== request.origin))
+ return null
+ return `${page.pathname}${page.search}`
+}
+
+function clientKey(event: H3Event): string {
+ // Cloudflare owns this header. Never trust X-Forwarded-For.
+ return getHeader(event, 'cf-connecting-ip') ?? 'unknown'
+}
+
+function proofSession(event: H3Event) {
+ const config = useRuntimeConfig(event)
+ return useSession(event, {
+ name: 'skilld-signup-click',
+ password: process.env.NUXT_SESSION_PASSWORD || config.session.password,
+ maxAge: PROOF_SECONDS,
+ cookie: { httpOnly: true, sameSite: 'strict', secure: !import.meta.dev, path: '/' },
+ })
+}
+
+async function withinAllowance(event: H3Event): Promise {
+ const result = await event.context.platform.env.API_GUEST_RATE_LIMIT.limit({ key: `signup-click:${clientKey(event)}` })
+ return result.success
+}
+
+export async function issueSignupClickProof(event: H3Event): Promise {
+ setHeader(event, 'cache-control', 'private, no-store')
+ setHeader(event, 'cloudflare-cdn-cache-control', 'no-store')
+ const page = browserPage(event)
+ if (!page || !await withinAllowance(event))
+ return null
+ const nonce = crypto.randomUUID()
+ const now = Math.floor(Date.now() / 1000)
+ const expiresAt = now + PROOF_SECONDS
+ const session = await proofSession(event)
+ const db = event.context.platform.db
+ await db.batch([
+ db.prepare('DELETE FROM signup_click_proofs WHERE expires_at <= ?1').bind(now),
+ db.prepare('INSERT INTO signup_click_proofs (nonce, expires_at) VALUES (?1, ?2)').bind(nonce, expiresAt),
+ ])
+ await session.update({ nonce, page, client: clientKey(event), expiresAt })
+ return nonce
+}
+
+export async function consumeSignupClickProof(event: H3Event): Promise {
+ const nonce = getHeader(event, 'x-signup-proof')
+ const page = browserPage(event)
+ if (!nonce || !page)
+ return false
+ const session = await proofSession(event)
+ const proof = session.data
+ const now = Math.floor(Date.now() / 1000)
+ if (proof.nonce !== nonce || proof.page !== page || proof.client !== clientKey(event) || !proof.expiresAt || proof.expiresAt <= now)
+ return false
+ if (!await withinAllowance(event))
+ return false
+ // Deleting atomically also rejects a replay of an older sealed cookie.
+ const row = await event.context.platform.db.prepare(
+ 'DELETE FROM signup_click_proofs WHERE nonce = ?1 AND expires_at > ?2 RETURNING nonce',
+ ).bind(nonce, now).first<{ nonce: string }>()
+ return row !== null
+}
diff --git a/migrations/0155_signup_click_proofs.sql b/migrations/0155_signup_click_proofs.sql
new file mode 100644
index 00000000..fd42de63
--- /dev/null
+++ b/migrations/0155_signup_click_proofs.sql
@@ -0,0 +1,6 @@
+-- Single-use proof for anonymous browser click evidence.
+CREATE TABLE signup_click_proofs (
+ nonce TEXT PRIMARY KEY,
+ expires_at INTEGER NOT NULL
+);
+CREATE INDEX idx_signup_click_proofs_expiry ON signup_click_proofs(expires_at);
diff --git a/test/unit/signup-click-proof.test.ts b/test/unit/signup-click-proof.test.ts
new file mode 100644
index 00000000..84b04b2b
--- /dev/null
+++ b/test/unit/signup-click-proof.test.ts
@@ -0,0 +1,101 @@
+import type { H3Event } from 'h3'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { consumeSignupClickProof, issueSignupClickProof } from '../../layers/identity/server/utils/signup-click-proof'
+import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite'
+
+const sessions = vi.hoisted(() => new Map }>())
+vi.mock('nitropack/runtime', () => ({ useRuntimeConfig: () => ({ session: { password: 'test-password' } }) }))
+vi.mock('h3', async original => ({
+ ...await original(),
+ useSession: async (event: H3Event, options: { name: string }) => {
+ expect(options.name).toBe('skilld-signup-click')
+ const client = String(event.node.req.headers['cf-connecting-ip'])
+ const session = sessions.get(client) ?? { data: {} }
+ sessions.set(client, session)
+ return {
+ data: session.data,
+ update: async (data: Record) => {
+ session.data = data
+ },
+ }
+ },
+}))
+
+let d1: ReturnType
+const limit = vi.fn()
+beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-10-12T00:00:00Z'))
+ d1 = createSqliteD1(allMigrations())
+ sessions.clear()
+ limit.mockReset().mockResolvedValue({ success: true })
+})
+afterEach(() => {
+ d1.close()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+})
+
+function event(headers: Record = {}): H3Event {
+ return {
+ node: {
+ req: { headers: { 'host': 'skilld.dev', 'referer': 'https://skilld.dev/login?action=like-skill', 'cf-connecting-ip': '192.0.2.1', ...headers }, connection: { encrypted: true } },
+ res: { setHeader() {} },
+ },
+ path: '/api/events/signup-proof',
+ context: { platform: { db: d1.db, env: { API_GUEST_RATE_LIMIT: { limit } } } },
+ } as unknown as H3Event
+}
+
+describe('anonymous click proof', () => {
+ it('allows one same-page click and rejects replay of the sealed cookie', async () => {
+ const nonce = await issueSignupClickProof(event())
+ expect(nonce).toBeTruthy()
+ await expect(consumeSignupClickProof(event({ 'x-signup-proof': nonce! }))).resolves.toBe(true)
+ await expect(consumeSignupClickProof(event({ 'x-signup-proof': nonce! }))).resolves.toBe(false)
+ })
+
+ it.each([
+ { 'x-signup-proof': 'forged' },
+ { referer: 'https://skilld.dev/skills' },
+ { referer: 'https://other.example/login' },
+ { origin: 'https://other.example' },
+ { 'cf-connecting-ip': '192.0.2.2' },
+ ])('refuses a mismatched proof without consuming the valid nonce: %j', async (headers) => {
+ const nonce = await issueSignupClickProof(event())
+ await expect(consumeSignupClickProof(event({ 'x-signup-proof': nonce!, ...headers }))).resolves.toBe(false)
+ await expect(consumeSignupClickProof(event({ 'x-signup-proof': nonce! }))).resolves.toBe(true)
+ })
+
+ it('expires the proof after five minutes', async () => {
+ const nonce = await issueSignupClickProof(event())
+ vi.advanceTimersByTime(300_000)
+ await expect(consumeSignupClickProof(event({ 'x-signup-proof': nonce! }))).resolves.toBe(false)
+ })
+
+ it('accepts only one of two simultaneous requests using the same proof', async () => {
+ const nonce = await issueSignupClickProof(event())
+ const outcomes = await Promise.all([
+ consumeSignupClickProof(event({ 'x-signup-proof': nonce! })),
+ consumeSignupClickProof(event({ 'x-signup-proof': nonce! })),
+ ])
+ expect(outcomes.filter(Boolean)).toHaveLength(1)
+ })
+
+ it('throttles issuance and clicks using the Cloudflare client identity', async () => {
+ limit.mockResolvedValueOnce({ success: false })
+ await expect(issueSignupClickProof(event())).resolves.toBeNull()
+ const nonce = await issueSignupClickProof(event())
+ limit.mockResolvedValueOnce({ success: false })
+ await expect(consumeSignupClickProof(event({ 'x-signup-proof': nonce! }))).resolves.toBe(false)
+ expect(limit).toHaveBeenLastCalledWith({ key: 'signup-click:192.0.2.1' })
+ })
+
+ it('removes expired nonce rows when preparing the next page', async () => {
+ const old = await issueSignupClickProof(event())
+ vi.advanceTimersByTime(300_000)
+ const nonce = await issueSignupClickProof(event())
+ expect(d1.raw.prepare('SELECT nonce FROM signup_click_proofs').all()).toEqual([{ nonce }])
+ expect(nonce).not.toBe(old)
+ })
+})
diff --git a/test/unit/signup-event-endpoint.test.ts b/test/unit/signup-event-endpoint.test.ts
index 829b37bb..3771a94d 100644
--- a/test/unit/signup-event-endpoint.test.ts
+++ b/test/unit/signup-event-endpoint.test.ts
@@ -29,9 +29,9 @@ it('refuses counts from a signed-out request', async () => {
expect(mocks.write).not.toHaveBeenCalled()
})
-it('accepts an anonymous sign-in click without granting access to setup events', async () => {
- await expect(request({ stage: 'oauth', entry: 'like-skill', outcome: 'clicked' }, false)).resolves.toEqual({ ok: true })
- expect(mocks.write).toHaveBeenCalledWith({ blobs: ['oauth', '', 'signup', 'like-skill', '', 'clicked', ''], doubles: [0, 0, 1], indexes: ['signup:oauth'] })
+it('refuses a same-origin anonymous click without a page proof', async () => {
+ await expect(request({ stage: 'oauth', entry: 'direct', outcome: 'clicked' }, false)).rejects.toMatchObject({ statusCode: 403 })
+ expect(mocks.write).not.toHaveBeenCalled()
})
it('refuses browser claims about server sign-in outcomes', async () => {
From cb3feb1aa6e410f2d1bbcff390f30f68225aac50 Mon Sep 17 00:00:00 2001
From: Harlan Wilton
Date: Mon, 12 Oct 2026 13:26:44 +1100
Subject: [PATCH 08/14] fix(identity): isolate public imports and handle
invalid GitHub JSON
---
.../identity/server/api/me/stars/sync.post.ts | 2 +
.../api/v1/account/stars/import.post.ts | 10 +-
layers/identity/server/utils/starred-repos.ts | 67 +++++++---
test/unit/starred-import-coverage.test.ts | 9 +-
test/unit/starred-import-operation.test.ts | 121 ++++++++++++++++++
test/unit/starred-import-provider.test.ts | 42 ++++++
6 files changed, 230 insertions(+), 21 deletions(-)
create mode 100644 test/unit/starred-import-operation.test.ts
create mode 100644 test/unit/starred-import-provider.test.ts
diff --git a/layers/identity/server/api/me/stars/sync.post.ts b/layers/identity/server/api/me/stars/sync.post.ts
index e438a9fb..45e9640e 100644
--- a/layers/identity/server/api/me/stars/sync.post.ts
+++ b/layers/identity/server/api/me/stars/sync.post.ts
@@ -17,6 +17,8 @@ export default defineApiHandler({
const result = await importStarredPage({ db: platform.db, userId: u.id, githubToken, page: body.page, importId: body.importId })
if (result._tag === 'ImportRestartRequired')
throw createError({ statusCode: 409, message: 'Another star import started. Import your stars again.' })
+ if (result._tag === 'GithubInvalidResponse')
+ throw createError({ statusCode: 502, message: 'Could not import GitHub stars. Try again.' })
if (result._tag === 'GithubSignInRequired')
throw createError({ statusCode: 401, statusMessage: 'GitHub sign-in required', message: 'GitHub rejected your access. Sign in with GitHub again.' })
if (result._tag === 'GithubFailed')
diff --git a/layers/identity/server/api/v1/account/stars/import.post.ts b/layers/identity/server/api/v1/account/stars/import.post.ts
index bda16cd4..5595b79d 100644
--- a/layers/identity/server/api/v1/account/stars/import.post.ts
+++ b/layers/identity/server/api/v1/account/stars/import.post.ts
@@ -1,7 +1,7 @@
import { starsV1 } from 'skilld-sdk/contract'
import { defineApiOperation, operationFailure } from '#shared/server/operation'
import { presentStarsImport } from '../../../../presenters/account-v1'
-import { importStarredPage } from '../../../../utils/starred-repos'
+import { importStarredRepositories } from '../../../../utils/starred-repos'
import { requireGithubUserToken } from '../../../../utils/users'
export default defineApiOperation({
@@ -9,15 +9,19 @@ export default defineApiOperation({
handler: async ({ event, platform, input, user }) => {
// Throws the 401 that asks for a new GitHub sign-in when no token is stored.
const githubToken = await requireGithubUserToken(platform.db, user.id, useRuntimeConfig(event).tokenKey as string)
- const result = await importStarredPage({ db: platform.db, userId: user.id, githubToken, page: input.body.page })
+ const result = await importStarredRepositories({ db: platform.db, userId: user.id, githubToken })
if (result._tag === 'ImportRestartRequired')
throw createError({ statusCode: 400, message: 'Star import pages must run in order. Restart from page one.' })
+ if (result._tag === 'GithubInvalidResponse')
+ throw createError({ statusCode: 503, message: 'Could not import GitHub stars. Try again.' })
if (result._tag === 'GithubSignInRequired')
return operationFailure('AUTH_REQUIRED', 'GitHub rejected skilld\'s access to your account. Sign in on skilld.dev again.')
// SERVICE_UNAVAILABLE is implicit, so no handler returns it as a value:
// a thrown 503 maps onto it, and the adapter logs the GitHub status.
if (result._tag === 'GithubFailed')
throw createError({ statusCode: 503, message: `GitHub answered ${result.status} to the star import` })
- return presentStarsImport(result)
+ // Echo accepted legacy input. No caller-visible continuation can attach
+ // to another request's generation; this response always ends the import.
+ return presentStarsImport({ ...result, page: input.body.page })
},
})
diff --git a/layers/identity/server/utils/starred-repos.ts b/layers/identity/server/utils/starred-repos.ts
index f5062f01..241d29f1 100644
--- a/layers/identity/server/utils/starred-repos.ts
+++ b/layers/identity/server/utils/starred-repos.ts
@@ -64,6 +64,7 @@ export type StarsImportPage
}
| { _tag: 'GithubSignInRequired' }
| { _tag: 'GithubFailed', status: number }
+ | { _tag: 'GithubInvalidResponse' }
| { _tag: 'ImportRestartRequired' }
/**
@@ -90,7 +91,7 @@ export async function importStarredPage(input: {
: await db.prepare(
'SELECT import_id, next_page FROM user_starred_import_state WHERE user_id = ?1',
).bind(userId).first<{ import_id: string, next_page: number }>()
- if (page !== 1 && (!state || state.next_page !== page || (input.importId && input.importId !== state.import_id)))
+ if (page !== 1 && (!state || state.next_page !== page || input.importId !== state.import_id))
return { _tag: 'ImportRestartRequired' }
const importId = page === 1 ? crypto.randomUUID() : state!.import_id
@@ -103,10 +104,22 @@ export async function importStarredPage(input: {
})
if (!res.ok)
return res.status === 401 ? { _tag: 'GithubSignInRequired' } : { _tag: 'GithubFailed', status: res.status }
- const raw = githubStarredPageSchema.parse(await res.json())
+ const decoded = await res.json().then(
+ value => ({ _tag: 'Decoded' as const, value }),
+ ).catch((error: unknown) => {
+ if (typeof error === 'object' && error !== null && 'name' in error && error.name === 'SyntaxError')
+ return { _tag: 'InvalidJson' as const }
+ throw error
+ })
+ if (decoded._tag === 'InvalidJson')
+ return { _tag: 'GithubInvalidResponse' }
+ const parsed = githubStarredPageSchema.safeParse(decoded.value)
+ if (!parsed.success)
+ return { _tag: 'GithubInvalidResponse' }
+ const raw = parsed.data
const items = raw
- const guard = 'EXISTS (SELECT 1 FROM user_starred_import_state WHERE user_id = ?1 AND import_id = ?5 AND next_page = ?6)'
+ const stateGuard = 'EXISTS (SELECT 1 FROM user_starred_import_state WHERE user_id = ?1 AND import_id = ?2 AND next_page = ?3)'
const statements: D1PreparedStatement[] = []
if (page === 1) {
statements.push(
@@ -115,20 +128,24 @@ export async function importStarredPage(input: {
ON CONFLICT (user_id) DO UPDATE SET import_id = excluded.import_id, next_page = 1`).bind(userId, importId),
)
}
- statements.push(...items.map((it) => {
- const owner = it.repo.owner.login
- const repo = it.repo.name
- const starredAt = Math.floor(new Date(it.starred_at).getTime() / 1000)
- return db.prepare(
- `INSERT OR REPLACE INTO user_starred_import (user_id, owner, repo, starred_at, has_skill)
- SELECT ?1,
- COALESCE((SELECT owner FROM repos WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?2),
- COALESCE((SELECT repo FROM repos WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE), ?3), ?4,
- (SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END
- FROM skills WHERE owner = ?2 COLLATE NOCASE AND repo = ?3 COLLATE NOCASE)
- WHERE ${guard}`,
- ).bind(userId, owner, repo, starredAt, importId, page)
+ // One bound JSON page keeps a complete ten-page import below D1's query budget.
+ const staged = items.map(item => ({
+ owner: item.repo.owner.login,
+ repo: item.repo.name,
+ starredAt: Math.floor(new Date(item.starred_at).getTime() / 1000),
}))
+ statements.push(db.prepare(
+ `INSERT OR REPLACE INTO user_starred_import (user_id, owner, repo, starred_at, has_skill)
+ WITH page_stars AS (
+ SELECT json_extract(value, '$.owner') AS owner, json_extract(value, '$.repo') AS repo,
+ json_extract(value, '$.starredAt') AS starred_at FROM json_each(?4)
+ )
+ SELECT ?1, COALESCE(r.owner, star.owner), COALESCE(r.repo, star.repo), star.starred_at,
+ EXISTS (SELECT 1 FROM skills sk WHERE sk.owner = star.owner COLLATE NOCASE AND sk.repo = star.repo COLLATE NOCASE)
+ FROM page_stars star
+ LEFT JOIN repos r ON r.owner = star.owner COLLATE NOCASE AND r.repo = star.repo COLLATE NOCASE
+ WHERE ${stateGuard}`,
+ ).bind(userId, importId, page, JSON.stringify(staged)))
const link = res.headers.get('link')
const hasNextPage = link ? /rel="next"/.test(link) : raw.length === PER_PAGE
@@ -140,7 +157,6 @@ export async function importStarredPage(input: {
// Every mutation and the page advance share one D1 transaction. A stale
// request cannot insert, publish, or clear another browser's generation.
- const stateGuard = 'EXISTS (SELECT 1 FROM user_starred_import_state WHERE user_id = ?1 AND import_id = ?2 AND next_page = ?3)'
const countIndex = statements.length
statements.push(db.prepare(
'SELECT COUNT(*) AS n, SUM(has_skill) AS matched FROM user_starred_import WHERE user_id = ?1',
@@ -177,3 +193,20 @@ export async function importStarredPage(input: {
importedAt,
}
}
+
+/**
+ * A public request owns every bounded GitHub page. The frozen SDK cannot carry
+ * a generation, so its pagination stays inside this request.
+ */
+export async function importStarredRepositories(
+ input: Omit[0], 'page' | 'importId'>,
+): Promise {
+ let importId: string | undefined
+ for (let page = 1; page <= MAX_STAR_PAGES; page++) {
+ const result = await importStarredPage({ ...input, page, importId })
+ if (result._tag !== 'Imported' || !result.hasMore)
+ return result
+ importId = result.importId
+ }
+ throw new Error('Star import exceeded its bounded page limit')
+}
diff --git a/test/unit/starred-import-coverage.test.ts b/test/unit/starred-import-coverage.test.ts
index b84ffd1f..ebdc1bea 100644
--- a/test/unit/starred-import-coverage.test.ts
+++ b/test/unit/starred-import-coverage.test.ts
@@ -8,7 +8,9 @@ const star = (name: string) => ({ starred_at: '2026-10-05T00:00:00Z', repo: { na
describe('star import coverage', () => {
let d1: SqliteD1
+ let importId: string | undefined
beforeEach(() => {
+ importId = undefined
d1 = createSqliteD1(allMigrations())
d1.raw.exec(`INSERT OR REPLACE INTO users (id, github_id, login, created_at, last_login_at, stars_synced_at) VALUES (1, 11, 'reader', 1, 1, 9);
INSERT INTO repos (owner, repo) VALUES ('nuxt', 'ui');
@@ -17,7 +19,12 @@ describe('star import coverage', () => {
INSERT INTO skill_subscriptions (user_id, owner, repo, source, created_at) VALUES (1, 'nuxt', 'ui', 'manual', 1);`)
})
afterEach(() => d1.close())
- const request = (page: number, response: Response) => importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page, now: () => 20, fetch: async () => response })
+ const request = async (page: number, response: Response) => {
+ const result = await importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page, importId, now: () => 20, fetch: async () => response })
+ if (result._tag === 'Imported')
+ importId = result.importId
+ return result
+ }
it('presents partial coverage without a completion date', async () => {
expect(starredReposPresenter(await loadStarredRows(d1.db, 1), null, true)).toMatchObject({ syncedAt: null, partial: true })
diff --git a/test/unit/starred-import-operation.test.ts b/test/unit/starred-import-operation.test.ts
new file mode 100644
index 00000000..aa744725
--- /dev/null
+++ b/test/unit/starred-import-operation.test.ts
@@ -0,0 +1,121 @@
+import type { SqliteD1 } from './helpers/d1-sqlite'
+import { createApp, createError, eventHandler, readBody, toWebHandler } from 'h3'
+import { starsV1 } from 'skilld-sdk/contract'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import importStars from '../../layers/identity/server/api/v1/account/stars/import.post'
+import { importStarredPage, loadStarredRows } from '../../layers/identity/server/utils/starred-repos'
+import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite'
+
+vi.mock('../../shared/server/handler', async importOriginal => ({
+ ...await importOriginal(),
+ resolveRequestUser: async () => ({ id: 1, login: 'reader' }),
+}))
+vi.mock('../../layers/identity/server/utils/users', async importOriginal => ({
+ ...await importOriginal(),
+ requireGithubUserToken: async () => 'fixture',
+}))
+vi.mock('#app/nuxt', async importOriginal => ({
+ ...await importOriginal(),
+ useRuntimeConfig: () => ({ tokenKey: 'fixture' }),
+}))
+const star = (name: string) => ({ starred_at: '2026-10-05T00:00:00Z', repo: { name, owner: { login: 'reader' } } })
+
+describe('public bounded star import', () => {
+ let d1: SqliteD1
+ beforeEach(() => {
+ d1 = createSqliteD1(allMigrations())
+ d1.raw.exec(`INSERT OR REPLACE INTO users (id, github_id, login, created_at, last_login_at, stars_synced_at) VALUES (1, 11, 'reader', 1, 1, 9);
+ INSERT INTO user_starred_repos VALUES (1, 'old', 'usable', 1, 0)`)
+ vi.stubGlobal('createError', createError)
+ vi.stubGlobal('createWideEvent', (fields: unknown) => fields)
+ vi.stubGlobal('emitOperationalEvent', vi.fn())
+ vi.stubGlobal('readBody', readBody)
+ vi.stubGlobal('useRuntimeConfig', () => ({ tokenKey: 'fixture' }))
+ })
+ afterEach(() => {
+ d1.close()
+ vi.unstubAllGlobals()
+ })
+ function request(page = 1) {
+ const app = createApp()
+ app.use(eventHandler((event) => {
+ event.context.platform = { db: d1.db, requestId: 'stars_test' } as never
+ }))
+ app.use(importStars)
+ return toWebHandler(app)(new Request('http://localhost/api/v1/account/stars/import', { method: 'POST', body: JSON.stringify({ page }), headers: { 'content-type': 'application/json' } }))
+ }
+ const pages = (prefix: string) => async (url: string) => Response.json(new URL(url).searchParams.get('page') === '1'
+ ? Array.from({ length: 100 }, (_, i) => star(`${prefix}-${i}`))
+ : [star(`${prefix}-last`)])
+
+ it.each([1, 2])('completes all GitHub pages in a request with legacy page %s', async (page) => {
+ vi.stubGlobal('fetch', pages('complete'))
+ const response = await request(page)
+ expect(response.status).toBe(200)
+ expect(starsV1.operations.import.response.body.producer.parse(await response.json())).toMatchObject({ page, nextPage: null, imported: 101, withSkills: 0, importedAt: expect.any(String) })
+ expect(await loadStarredRows(d1.db, 1)).toHaveLength(101)
+ })
+ it('preserves the usable import if a later successful GitHub response is malformed', async () => {
+ vi.stubGlobal('fetch', async (url: string) => new URL(url).searchParams.get('page') === '1'
+ ? pages('staged')(url)
+ : Response.json({ message: 'Invalid page' }))
+ const response = await request()
+ expect(response.status).toBe(503)
+ expect(await response.json()).toMatchObject({ code: 'SERVICE_UNAVAILABLE' })
+ expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ owner: 'old', repo: 'usable' }])
+ expect(d1.raw.prepare('SELECT stars_synced_at FROM users WHERE id=1').get()).toMatchObject({ stars_synced_at: 9 })
+ })
+ it('maps malformed provider JSON to SERVICE_UNAVAILABLE without replacing usable stars', async () => {
+ vi.stubGlobal('fetch', async () => new Response('{'))
+ const response = await request()
+ expect(response.status).toBe(503)
+ expect(await response.json()).toMatchObject({ code: 'SERVICE_UNAVAILABLE' })
+ expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ owner: 'old', repo: 'usable' }])
+ })
+ it('stops a public import if a browser publishes a different generation', async () => {
+ let release!: (response: Response) => void
+ const delayed = new Promise((resolve) => {
+ release = resolve
+ })
+ let entered!: () => void
+ const fetchingSecondPage = new Promise((resolve) => {
+ entered = resolve
+ })
+ vi.stubGlobal('fetch', async (url: string) => {
+ if (new URL(url).searchParams.get('page') === '1')
+ return pages('public')(url)
+ entered()
+ return delayed
+ })
+ const older = request()
+ await fetchingSecondPage
+ await importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page: 1, fetch: async () => Response.json([star('browser')]) })
+ release(Response.json([star('public-last')]))
+ expect((await older).status).toBe(400)
+ expect((await loadStarredRows(d1.db, 1)).map(row => row.repo)).toEqual(['browser'])
+ })
+ it('prevents an older concurrent full import from publishing mixed rows', async () => {
+ let release!: (response: Response) => void
+ const delayed = new Promise((resolve) => {
+ release = resolve
+ })
+ let entered!: () => void
+ const fetchingSecondPage = new Promise((resolve) => {
+ entered = resolve
+ })
+ vi.stubGlobal('fetch', async (url: string) => {
+ if (new URL(url).searchParams.get('page') === '1')
+ return pages('older')(url)
+ entered()
+ return delayed
+ })
+ const older = request()
+ await fetchingSecondPage
+ vi.stubGlobal('fetch', pages('newer'))
+ const newer = await request()
+ release(Response.json([star('older-last')]))
+ expect(newer.status).toBe(200)
+ expect((await older).status).toBe(400)
+ expect((await loadStarredRows(d1.db, 1)).map(row => row.repo).sort()).toEqual([...Array.from({ length: 100 }, (_, i) => `newer-${i}`), 'newer-last'].sort())
+ })
+})
diff --git a/test/unit/starred-import-provider.test.ts b/test/unit/starred-import-provider.test.ts
new file mode 100644
index 00000000..530ec2ef
--- /dev/null
+++ b/test/unit/starred-import-provider.test.ts
@@ -0,0 +1,42 @@
+import type { SqliteD1 } from './helpers/d1-sqlite'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { importStarredPage, importStarredRepositories, loadStarredRows } from '../../layers/identity/server/utils/starred-repos'
+import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite'
+
+const star = (name: string) => ({ starred_at: '2026-10-05T00:00:00Z', repo: { name, owner: { login: 'reader' } } })
+describe('star import provider boundary', () => {
+ let d1: SqliteD1
+ beforeEach(() => {
+ d1 = createSqliteD1(allMigrations())
+ d1.raw.exec(`INSERT OR REPLACE INTO users (id, github_id, login, created_at, last_login_at, stars_synced_at) VALUES (1, 11, 'reader', 1, 1, 9);
+ INSERT INTO user_starred_repos VALUES (1, 'old', 'usable', 1, 0)`)
+ })
+ afterEach(() => d1.close())
+ it.each([
+ () => new Response('{'),
+ () => Response.json({ message: 'Not a star list' }),
+ () => Response.json([star('ui'), { repo: { name: 'invalid' } }]),
+ ])('returns a recoverable provider failure for malformed successful responses', async (response) => {
+ expect(await importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page: 1, fetch: async () => response() })).toEqual({ _tag: 'GithubInvalidResponse' })
+ expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ owner: 'old', repo: 'usable' }])
+ expect(d1.raw.prepare('SELECT stars_synced_at FROM users WHERE id=1').get()).toMatchObject({ stars_synced_at: 9 })
+ })
+ it('rejects generationless continuations instead of attaching them to a newer import', async () => {
+ await importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page: 1, fetch: async () => Response.json(Array.from({ length: 100 }, (_, i) => star(`new-${i}`))) })
+ expect(await importStarredPage({ db: d1.db, userId: 1, githubToken: 'fixture', page: 2, fetch: async () => Response.json([star('delayed')]) })).toEqual({ _tag: 'ImportRestartRequired' })
+ expect(await loadStarredRows(d1.db, 1)).toMatchObject([{ owner: 'old', repo: 'usable' }])
+ })
+})
+
+describe('bounded server-owned import', () => {
+ it('keeps 1000 stars within the per-request D1 query budget and reports partial coverage', async () => {
+ const d1 = createSqliteD1(allMigrations(), { maximumQueries: 50 })
+ d1.raw.exec('INSERT OR REPLACE INTO users (id, github_id, login, created_at, last_login_at) VALUES (1, 11, \'reader\', 1, 1)')
+ const fetch = vi.fn(async (url: string) => Response.json(Array.from({ length: 100 }, (_, i) => star(`p${new URL(url).searchParams.get('page')}-${i}`))))
+ const result = await importStarredRepositories({ db: d1.db, userId: 1, githubToken: 'fixture', fetch })
+ expect(result).toMatchObject({ _tag: 'Imported', total: 1000, importedAt: null, hasMore: false })
+ expect(fetch).toHaveBeenCalledTimes(10)
+ expect(await loadStarredRows(d1.db, 1)).toHaveLength(1000)
+ d1.close()
+ })
+})
From 0cef67c55b134147bbddd7fb08bab807b0435919 Mon Sep 17 00:00:00 2001
From: Harlan Wilton
Date: Mon, 12 Oct 2026 13:49:27 +1100
Subject: [PATCH 09/14] fix(identity): match NuxtSEO account entry layout
---
layers/identity/app/layouts/auth.vue | 6 ++-
layers/identity/app/pages/login.vue | 66 ++++++++++++++--------------
2 files changed, 39 insertions(+), 33 deletions(-)
diff --git a/layers/identity/app/layouts/auth.vue b/layers/identity/app/layouts/auth.vue
index 5f99dfcc..dbfd79ac 100644
--- a/layers/identity/app/layouts/auth.vue
+++ b/layers/identity/app/layouts/auth.vue
@@ -1,3 +1,7 @@
+
+