diff --git a/COPY.md b/COPY.md
index ae22d546..cbc4cecc 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,28 @@ 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 with GitHub · Sign Up with GitHub · Continue with GitHub | Sign-in and sign-up share GitHub authentication. Preserve the requested action and return page when switching. |
+| Login switch | New to skilld? · Sign up · Already have an account? · Sign in | Link between the sign-in and sign-up views. Sign-up shows the account benefits. |
+| Login description | Keep your liked Skills and watched Repositories together. | Explain account value below either authentication heading. |
+| 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 benefits heading | Keep updated. | Sign-up benefits panel. |
+| 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. · 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. |
+
## Register by context
| Context | Register | Example |
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
-
- 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.
-
-
-
+
+
+
+
+ {{ signup ? 'Sign Up with GitHub' : 'Sign In with GitHub' }}
+
+
+ {{ actionMessage || 'Keep your liked Skills and watched Repositories together.' }}
+
+
+
+ Continue with GitHub
+
+
+
+ Couldn't complete sign-in. Try again.
+
+
+ {{ signup ? 'Already have an account?' : 'New to skilld?' }}
+
+ {{ signup ? 'Sign in' : 'Sign up' }}
+
+
-
- Couldn't complete sign-in. Try again.
+
+
+ We ask for your public profile and email address.
- 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.
- 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.
-
- 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/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/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/api/me/starred.get.ts b/layers/identity/server/api/me/starred.get.ts
index 7121934d..d4582787 100644
--- a/layers/identity/server/api/me/starred.get.ts
+++ b/layers/identity/server/api/me/starred.get.ts
@@ -6,8 +6,16 @@ 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)
- 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 {
+ 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 ab270d87..45e9640e 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,25 +8,22 @@ 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)
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 === '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')
throw createError({ statusCode: 502, message: `GitHub error ${result.status}` })
- return {
- ok: true as const,
- page: result.page,
- fetched: result.fetched,
- total: result.total,
- matched: result.matched,
- hasMore: result.hasMore,
- syncedAt: result.importedAt,
- }
+ return result
},
})
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..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,13 +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/policies/signup-event.ts b/layers/identity/server/policies/signup-event.ts
new file mode 100644
index 00000000..986938eb
--- /dev/null
+++ b/layers/identity/server/policies/signup-event.ts
@@ -0,0 +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' ? consumeSignupClickProof(context.event) : authenticated(context)
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/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/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/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/layers/identity/server/utils/starred-repos.ts b/layers/identity/server/utils/starred-repos.ts
index 87a881b0..241d29f1 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. */
@@ -20,25 +21,28 @@ 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 ?? []
}
-interface GitHubStarred {
- starred_at: string
- repo: { name: string, owner: { login: string } }
-}
+const githubStarredPageSchema = z.array(z.object({
+ starred_at: z.iso.datetime({ offset: true }),
+ repo: z.object({ name: z.string().min(1), owner: z.object({ login: z.string().min(1) }) }),
+})).max(100)
const PER_PAGE = 100
export const MAX_STAR_PAGES = 10
@@ -47,6 +51,7 @@ export type StarsImportPage
= | {
_tag: 'Imported'
page: number
+ importId: string
/** Starred Repositories this page kept. */
fetched: number
/** Starred Repositories stored so far. */
@@ -59,19 +64,21 @@ export type StarsImportPage
}
| { _tag: 'GithubSignInRequired' }
| { _tag: 'GithubFailed', status: number }
+ | { _tag: 'GithubInvalidResponse' }
+ | { _tag: 'ImportRestartRequired' }
/**
* Import one page of a person's GitHub stars.
*
- * Page 1 clears the last import first, so a run of pages replaces it. GitHub
- * failures come back as values: the dashboard and the public API answer them
- * with different statuses.
+ * Pages stage separately from the last usable import. A terminal page
+ * publishes the staged rows atomically. GitHub failures leave active rows intact.
*/
export async function importStarredPage(input: {
db: D1Database
userId: number
githubToken: string
page: number
+ importId?: string
fetch?: typeof globalThis.fetch
now?: () => number
}): Promise {
@@ -79,8 +86,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 !== 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 +104,88 @@ 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))
-
- 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 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 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(
+ 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),
+ )
}
+ // 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
+ const hasMore = hasNextPage && page < MAX_STAR_PAGES
- const hasMore = raw.length === PER_PAGE && page < MAX_STAR_PAGES
+ // 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
- let importedAt: number | null = null
+ // Every mutation and the page advance share one D1 transaction. A stale
+ // request cannot insert, publish, or clear another browser's generation.
+ 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,
@@ -135,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/layers/identity/server/utils/watch-actions.ts b/layers/identity/server/utils/watch-actions.ts
index a53756d4..95f97365 100644
--- a/layers/identity/server/utils/watch-actions.ts
+++ b/layers/identity/server/utils/watch-actions.ts
@@ -1,4 +1,5 @@
import type { H3Event } from 'h3'
+import { z } from 'zod'
import { likeSkill } from './likes'
import { createRepositoryWatches } from './watches'
@@ -49,8 +50,22 @@ export async function handleWatchAction(
// likeSkill derives the repo subscription itself, so there is no separate
// watch insert here.
- if (action === 'like-skill' && ctx.skillOwner && ctx.skillRepo && ctx.skillName) {
- await likeSkill(d, userId, { owner: ctx.skillOwner, repo: ctx.skillRepo, name: ctx.skillName })
+ if (action === 'like-skill' && ctx.skillOwner && ctx.skillRepo) {
+ let name = ctx.skillName
+ if (!name) {
+ // The registry owns canonical repository paths. Ask it whether this path
+ // represents exactly one Skill, rather than guessing a Skill name.
+ const resolution = z.object({
+ target: z.discriminatedUnion('_tag', [
+ z.object({ _tag: z.literal('repo') }),
+ z.object({ _tag: z.literal('skill'), name: z.string().min(1) }),
+ ]),
+ }).parse(await $fetch(`/api/repos/${encodeURIComponent(ctx.skillOwner)}/${encodeURIComponent(ctx.skillRepo)}/route-target`))
+ if (resolution.target._tag !== 'skill')
+ throw createError({ statusCode: 400, message: 'Open a Skill before liking it.' })
+ name = resolution.target.name
+ }
+ await likeSkill(d, userId, { owner: ctx.skillOwner, repo: ctx.skillRepo, name })
return
}
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"
/>