mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

feat: verify installation ownership before binding a tangled handle

+523 -7
+6
.env.example
··· 53 53 # - The webhook secret you set during creation. 54 54 # - A generated private key (.pem). On Vercel, store with literal "\n" in 55 55 # place of newlines; locally, keep the real newlines. 56 + # - The Client ID and a generated client secret ("Client secrets" section). 57 + # These drive the user-to-server OAuth that proves a connecting user 58 + # actually administers the installation they're binding a tangled handle 59 + # to. Distinct from the private key above. Required for the /connect flow. 56 60 # --------------------------------------------------------------------------- 57 61 NUXT_GITHUB_APP_ID=<numeric app id> 58 62 NUXT_GITHUB_WEBHOOK_SECRET=<webhook secret> 63 + NUXT_GITHUB_APP_CLIENT_ID=<github app client id, e.g. Iv1.abc123> 64 + NUXT_GITHUB_APP_CLIENT_SECRET=<github app client secret> 59 65 NUXT_GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- 60 66 ... 61 67 -----END RSA PRIVATE KEY-----
+12 -4
README.md
··· 45 45 Neon dashboard and a [new GitHub App](https://github.com/settings/apps/new). 46 46 The App needs `contents:read` and `metadata:read` permissions plus the `push`, 47 47 `create`, `delete`, and `repository` events, with its webhook pointed at your 48 - Smee URL. 48 + Smee URL. Set the **Setup URL** to `<origin>/connect` (tick *Redirect on 49 + update*) and the **Callback URL** to `<origin>/api/github/oauth/callback`; the 50 + latter drives the user-OAuth that verifies a connecting user administers the 51 + installation before any handle is bound. Copy the App's **Client ID** and a 52 + generated **client secret** into `NUXT_GITHUB_APP_CLIENT_ID` / 53 + `NUXT_GITHUB_APP_CLIENT_SECRET`. 49 54 50 55 In separate terminals, proxy webhooks and drain the job queue: 51 56 ··· 65 70 2. Import the repo into Vercel (the Nuxt preset is auto-detected) and set every 66 71 variable from `.env.example` under **Settings > Environment Variables**. 67 72 Mark the secrets (`NUXT_DATABASE_URL`, `NUXT_GITHUB_APP_PRIVATE_KEY`, 68 - `NUXT_ATPROTO_PRIVATE_JWK`, `NUXT_ENCRYPTION_KEY`, `NUXT_SESSION_PASSWORD`, 73 + `NUXT_GITHUB_APP_CLIENT_SECRET`, `NUXT_ATPROTO_PRIVATE_JWK`, 74 + `NUXT_ENCRYPTION_KEY`, `NUXT_SESSION_PASSWORD`, 69 75 `NUXT_GITHUB_WEBHOOK_SECRET`, `NUXT_CRON_SECRET`) as **Sensitive**. 70 - 3. Set `NUXT_PUBLIC_URL` to your real origin and point the GitHub App webhook at 71 - `https://<your-domain>/api/github/webhook`. 76 + 3. Set `NUXT_PUBLIC_URL` to your real origin, point the GitHub App webhook at 77 + `https://<your-domain>/api/github/webhook`, and set the App's Setup + 78 + Callback URLs to `https://<your-domain>/connect` and 79 + `https://<your-domain>/api/github/oauth/callback`. 72 80 4. Deploy. 73 81 74 82 The worker runs on a Vercel Cron (declared in `nuxt.config.ts`, so no
+222
app/pages/connect.vue
··· 1 + <script setup lang="ts"> 2 + import type { ConnectInfo } from '#server/api/connect/info.get' 3 + 4 + useSeoMeta({ 5 + title: 'Connect · synchub.to', 6 + description: 'Connect your tangled identity to your GitHub installation.', 7 + }) 8 + 9 + const route = useRoute() 10 + 11 + const installationId = computed(() => { 12 + const raw = route.query.installation_id 13 + return typeof raw === 'string' && /^\d+$/.test(raw) ? raw : null 14 + }) 15 + const verified = computed(() => route.query.verified === '1') 16 + 17 + const { data: info } = await useFetch<ConnectInfo>('/api/connect/info', { 18 + query: { installationId }, 19 + immediate: !!installationId.value, 20 + ignoreResponseError: true, 21 + }) 22 + 23 + const login = computed(() => 24 + (typeof route.query.login === 'string' ? route.query.login : null) ?? info.value?.login ?? null, 25 + ) 26 + const accountLabel = computed(() => login.value ?? 'your GitHub account') 27 + </script> 28 + 29 + <template> 30 + <div class="shell"> 31 + <header class="nav-term"> 32 + <nav class="nav-term__line" aria-label="primary"> 33 + <span class="prompt" aria-hidden="true">&gt;</span> 34 + <a class="nav-term__mark" href="/"><SynchubMark :wordmark="true" :size="18" /></a> 35 + </nav> 36 + </header> 37 + 38 + <main class="connect"> 39 + <div v-if="!installationId" class="panel"> 40 + <h1 class="connect__title">No installation to connect</h1> 41 + <p class="connect__body"> 42 + Start by installing the GitHub App on the repositories you'd like to 43 + mirror. 44 + </p> 45 + <a class="btn btn--primary" href="https://github.com/apps/synchub-to/installations/new">Install the GitHub App</a> 46 + </div> 47 + 48 + <div v-else-if="!verified" class="panel"> 49 + <p class="connect__eyebrow">step 1 of 2 &middot; verify</p> 50 + <h1 class="connect__title">Installed on <span class="connect__login">{{ accountLabel }}</span></h1> 51 + <p class="connect__body"> 52 + Confirm you administer <strong>{{ accountLabel }}</strong> on GitHub, 53 + then you'll pick the tangled handle that mirrors it. We check this so 54 + nobody else can bind your repositories to their identity. 55 + </p> 56 + <a class="btn btn--primary" :href="`/api/github/oauth/start?installationId=${installationId}`"> 57 + Verify with GitHub 58 + </a> 59 + </div> 60 + 61 + <div v-else class="panel"> 62 + <p class="connect__eyebrow">step 2 of 2 &middot; connect</p> 63 + <h1 class="connect__title">Connect a handle to <span class="connect__login">{{ accountLabel }}</span></h1> 64 + <p class="connect__body"> 65 + Enter the tangled handle that should mirror 66 + <strong>{{ accountLabel }}</strong>. One GitHub account maps to one 67 + tangled identity; connecting a new handle replaces any previous one. 68 + </p> 69 + <form class="signin" action="/api/atproto/login" method="get"> 70 + <input type="hidden" name="installationId" :value="installationId"> 71 + <label class="signin__label"> 72 + <span>handle</span> 73 + <span class="signin__field"> 74 + <span class="signin__prompt" aria-hidden="true">@</span> 75 + <input 76 + type="text" 77 + name="handle" 78 + required 79 + placeholder="alice.bsky.social" 80 + autocomplete="username" 81 + autocapitalize="none" 82 + autocorrect="off" 83 + spellcheck="false" 84 + > 85 + </span> 86 + </label> 87 + <button class="btn btn--primary" type="submit">Connect</button> 88 + </form> 89 + </div> 90 + </main> 91 + </div> 92 + </template> 93 + 94 + <style scoped> 95 + .shell { 96 + max-width: 52rem; 97 + margin: 0 auto; 98 + padding-inline: var(--page-gutter); 99 + } 100 + 101 + .nav-term { 102 + padding: var(--space-md) 0; 103 + border-bottom: var(--rule-hair) solid var(--color-rule); 104 + } 105 + 106 + .nav-term__line { 107 + display: flex; 108 + align-items: center; 109 + gap: 0.6ch; 110 + font-family: var(--font-mono); 111 + font-size: var(--text-sm); 112 + margin: 0; 113 + } 114 + 115 + .nav-term__line .prompt { color: var(--color-accent); } 116 + .nav-term__mark { display: inline-flex; text-decoration: none; } 117 + 118 + .connect { 119 + padding-block: var(--space-2xl) var(--space-xl); 120 + } 121 + 122 + .panel { 123 + max-width: 38rem; 124 + } 125 + 126 + .connect__eyebrow { 127 + font-family: var(--font-mono); 128 + font-size: var(--text-xs); 129 + letter-spacing: 0.08em; 130 + text-transform: uppercase; 131 + color: var(--color-accent-dim); 132 + margin: 0 0 var(--space-md); 133 + } 134 + 135 + .connect__title { 136 + font-size: var(--text-xl); 137 + line-height: 1.1; 138 + margin: 0 0 var(--space-md); 139 + overflow-wrap: anywhere; 140 + min-width: 0; 141 + } 142 + 143 + .connect__login { color: var(--color-accent); } 144 + 145 + .connect__body { 146 + max-width: var(--measure); 147 + color: var(--color-muted); 148 + margin: 0 0 var(--space-lg); 149 + } 150 + 151 + .signin { 152 + display: flex; 153 + flex-wrap: wrap; 154 + align-items: end; 155 + gap: var(--space-sm); 156 + } 157 + 158 + .signin__label { 159 + display: flex; 160 + flex-direction: column; 161 + gap: var(--space-2xs); 162 + font-family: var(--font-mono); 163 + font-size: var(--text-xs); 164 + letter-spacing: 0.06em; 165 + text-transform: uppercase; 166 + color: var(--color-neutral); 167 + } 168 + 169 + .signin__field { 170 + display: flex; 171 + align-items: center; 172 + background: var(--color-paper-2); 173 + border: var(--rule-hair) solid var(--color-rule-interactive); 174 + border-radius: var(--radius-sm); 175 + } 176 + 177 + .signin__field:focus-within { 178 + outline: 2px solid var(--color-focus); 179 + outline-offset: 2px; 180 + border-color: var(--color-accent); 181 + } 182 + 183 + .signin__prompt { 184 + padding-inline: 0.75ch 0; 185 + color: var(--color-accent); 186 + font-family: var(--font-mono); 187 + } 188 + 189 + .signin input:focus-visible { outline: none; } 190 + 191 + .signin input { 192 + flex: 1; 193 + min-width: 14rem; 194 + padding: 0.55rem 0.75rem 0.55rem 0.4ch; 195 + border: 0; 196 + background: transparent; 197 + color: var(--color-ink); 198 + font-family: var(--font-mono); 199 + font-size: var(--text-base); 200 + } 201 + 202 + .signin input::placeholder { color: var(--color-neutral); } 203 + 204 + .btn { 205 + display: inline-block; 206 + padding: 0.55rem 1.1rem; 207 + border: var(--rule-hair) solid var(--color-rule-interactive); 208 + border-radius: var(--radius-sm); 209 + background: var(--color-paper-2); 210 + color: var(--color-ink); 211 + font-family: var(--font-mono); 212 + font-size: var(--text-sm); 213 + text-decoration: none; 214 + white-space: nowrap; 215 + cursor: pointer; 216 + transition: transform var(--dur-micro) var(--ease-out), border-color var(--dur-micro) var(--ease-out); 217 + } 218 + 219 + .btn:hover { border-color: var(--color-accent); transform: translateY(-1px); } 220 + .btn:active { transform: translateY(0); } 221 + .btn--primary { border-color: var(--color-accent); color: var(--color-accent); } 222 + </style>
+3
nuxt.config.ts
··· 23 23 databaseUrl: '', 24 24 githubAppId: '', 25 25 githubAppPrivateKey: '', 26 + githubAppClientId: '', 27 + githubAppClientSecret: '', 26 28 githubWebhookSecret: '', 27 29 cronSecret: '', 28 30 workerBudgetMs: '', ··· 58 60 routeRules: { 59 61 '/': { noScripts: true, prerender: true }, 60 62 '/dashboard': { ssr: false, prerender: true }, 63 + '/connect': { ssr: false }, 61 64 }, 62 65 nitro: { 63 66 vercel: {
+16
server/api/atproto/callback.get.ts
··· 1 1 import { and, eq, ne } from 'drizzle-orm' 2 2 import { userIdentity } from '#server/db/schema' 3 3 import { enqueue } from '#server/utils/queue' 4 + import { clearInstallOwnership, hasVerifiedInstall } from '#server/utils/install-ownership' 4 5 import { resolveHandle } from '#server/utils/resolve-handle' 5 6 import { addAccount } from '#server/utils/server-session' 6 7 import { generateAndPublishKey, revokeKeyForInstallationDid } from '#server/utils/tangled-pubkey' ··· 39 40 } 40 41 installationId = parsed 41 42 43 + // Bind-guard: the connecting user must have proven (via GitHub user-OAuth 44 + // in the /connect flow) that they administer this installation. Without 45 + // this check, anyone who completes a tangled OAuth carrying a victim's 46 + // installation id in `state` could hijack the mirror. The proof is a 47 + // sealed cookie set by /api/github/oauth/callback. 48 + if (!(await hasVerifiedInstall(event, installationId))) { 49 + throw createError({ 50 + statusCode: 403, 51 + statusMessage: 'installation ownership not verified; start from the connect page', 52 + }) 53 + } 54 + 42 55 // One installation maps to exactly one DID. If another DID is currently 43 56 // bound to this installation, this connect displaces it: revoke that DID's 44 57 // now-dead SSH key (PDS record + local row) and null its installationId so ··· 87 100 // and fans out per-repo enrolment. Doing this in the worker (rather than 88 101 // inline here) keeps the OAuth callback fast regardless of repo count. 89 102 await enqueue('tangled.backfill-installation', { installationId, page: 1 }) 103 + 104 + // Consume the ownership proof so it can't be replayed for another bind. 105 + await clearInstallOwnership(event) 90 106 } 91 107 else { 92 108 // Returning sign-in. Look up the installation we previously bound.
+21
server/api/connect/info.get.ts
··· 1 + import { installationAccountLogin } from '#server/utils/github-app' 2 + 3 + export interface ConnectInfo { 4 + installationId: number 5 + login: string | null 6 + } 7 + 8 + /** 9 + * Public lookup of an installation's account login for the connect page. 10 + * Returns only the login, which the App can already read; it is not a secret 11 + * and reveals nothing the install screen didn't. The actual ownership gate is 12 + * the GitHub user-OAuth step, not this endpoint. 13 + */ 14 + export default defineEventHandler(async (event): Promise<ConnectInfo> => { 15 + const raw = getQuery(event).installationId 16 + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { 17 + throw createError({ statusCode: 400, statusMessage: 'installationId is required and must be numeric' }) 18 + } 19 + const installationId = Number(raw) 20 + return { installationId, login: await installationAccountLogin(installationId) } 21 + })
+55
server/api/github/oauth/callback.get.ts
··· 1 + import { installationAccountLogin, userAdministersInstallation, userOctokitFromCode } from '#server/utils/github-app' 2 + import { markInstallOwned } from '#server/utils/install-ownership' 3 + import { sessionConfig } from '#server/utils/server-session' 4 + 5 + interface OAuthFlowData { 6 + state: string 7 + installationId: number 8 + } 9 + 10 + /** 11 + * GitHub user-OAuth callback. Verifies the `state` against the sealed flow 12 + * cookie, exchanges the code for a user token, and confirms the user 13 + * administers the installation. On success, marks the install owned (a sealed 14 + * cookie the atproto callback checks before binding) and sends the user into 15 + * the tangled connect flow. 16 + */ 17 + export default defineEventHandler(async event => { 18 + const query = getQuery(event) 19 + const code = typeof query.code === 'string' ? query.code : null 20 + const state = typeof query.state === 'string' ? query.state : null 21 + if (!code || !state) { 22 + throw createError({ statusCode: 400, statusMessage: 'missing code or state' }) 23 + } 24 + 25 + const flow = await useSession<OAuthFlowData>(event, { 26 + ...sessionConfig(), 27 + name: 'synchub-gh-oauth', 28 + maxAge: 10 * 60, 29 + }) 30 + const expectedState = flow.data.state 31 + const installationId = flow.data.installationId 32 + await flow.clear() 33 + 34 + if (!expectedState || expectedState !== state || typeof installationId !== 'number') { 35 + throw createError({ statusCode: 400, statusMessage: 'invalid or expired oauth state' }) 36 + } 37 + 38 + const userOctokit = await userOctokitFromCode(code) 39 + const administers = await userAdministersInstallation(userOctokit, installationId) 40 + if (!administers) { 41 + throw createError({ 42 + statusCode: 403, 43 + statusMessage: 'your GitHub account does not administer this installation', 44 + }) 45 + } 46 + 47 + await markInstallOwned(event, installationId) 48 + 49 + // Ownership proven. Hand off to the connect page, which now lets the user 50 + // pick the tangled handle to bind. Carry the account login for display. 51 + const login = await installationAccountLogin(installationId) 52 + const params = new URLSearchParams({ installation_id: String(installationId), verified: '1' }) 53 + if (login) params.set('login', login) 54 + await sendRedirect(event, `/connect?${params.toString()}`, 302) 55 + })
+34
server/api/github/oauth/start.get.ts
··· 1 + import { randomBytes } from 'node:crypto' 2 + import { githubOAuthUrl } from '#server/utils/github-app' 3 + import { sessionConfig } from '#server/utils/server-session' 4 + 5 + interface OAuthFlowData { 6 + state: string 7 + installationId: number 8 + } 9 + 10 + /** 11 + * Begin GitHub user-to-server OAuth to prove the connecting user administers 12 + * the installation they're about to bind a tangled handle to. The random 13 + * `state` is sealed into a short-lived cookie and verified in the callback. 14 + */ 15 + export default defineEventHandler(async event => { 16 + const query = getQuery(event) 17 + const raw = query.installationId 18 + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { 19 + throw createError({ statusCode: 400, statusMessage: 'installationId is required and must be numeric' }) 20 + } 21 + const installationId = Number(raw) 22 + 23 + const state = randomBytes(16).toString('base64url') 24 + const flow = await useSession<OAuthFlowData>(event, { 25 + ...sessionConfig(), 26 + name: 'synchub-gh-oauth', 27 + maxAge: 10 * 60, 28 + }) 29 + await flow.update({ state, installationId }) 30 + 31 + const { url } = useRuntimeConfig().public 32 + const redirectUri = `${url.replace(/\/$/, '')}/api/github/oauth/callback` 33 + await sendRedirect(event, githubOAuthUrl({ state, redirectUri }), 302) 34 + })
+72
server/utils/github-app.ts
··· 9 9 if (!appId || !privateKey) { 10 10 throw new Error('NUXT_GITHUB_APP_ID and NUXT_GITHUB_APP_PRIVATE_KEY must be set') 11 11 } 12 + // The OAuth client id/secret are optional at construction time so the 13 + // webhook + sync paths (server-to-server only) keep working without them; 14 + // requireOAuthApp() throws explicitly when the /connect flow needs them. 15 + const clientId = process.env.NUXT_GITHUB_APP_CLIENT_ID 16 + const clientSecret = process.env.NUXT_GITHUB_APP_CLIENT_SECRET 12 17 cachedApp = new App({ 13 18 appId, 19 + ...(clientId && clientSecret ? { oauth: { clientId, clientSecret } } : {}), 14 20 // Vercel env vars escape newlines; restore them so PEM parsing works. 15 21 privateKey: privateKey.replaceAll('\\n', '\n'), 16 22 }) ··· 18 24 } 19 25 20 26 export type InstallationOctokit = Awaited<ReturnType<App['getInstallationOctokit']>> 27 + export type UserOctokit = Awaited<ReturnType<App['oauth']['getUserOctokit']>> 21 28 22 29 /** Get an Octokit pre-authed for a specific GitHub App installation. */ 23 30 export async function installationOctokit(installationId: number): Promise<InstallationOctokit> { 24 31 const app = useApp() 25 32 return app.getInstallationOctokit(installationId) 33 + } 34 + 35 + function requireOAuthApp(): App { 36 + if (!process.env.NUXT_GITHUB_APP_CLIENT_ID || !process.env.NUXT_GITHUB_APP_CLIENT_SECRET) { 37 + throw createError({ 38 + statusCode: 500, 39 + statusMessage: 'GitHub user-OAuth is not configured (set NUXT_GITHUB_APP_CLIENT_ID and NUXT_GITHUB_APP_CLIENT_SECRET)', 40 + }) 41 + } 42 + return useApp() 43 + } 44 + 45 + /** 46 + * Build the GitHub user-to-server OAuth authorize URL. `state` is round-tripped 47 + * back to the callback for CSRF protection; the callback verifies it against a 48 + * sealed cookie. 49 + */ 50 + export function githubOAuthUrl(opts: { state: string, redirectUri: string }): string { 51 + const app = requireOAuthApp() 52 + const { url } = app.oauth.getWebFlowAuthorizationUrl({ 53 + state: opts.state, 54 + redirectUrl: opts.redirectUri, 55 + allowSignup: false, 56 + }) 57 + return url 58 + } 59 + 60 + /** 61 + * Exchange an OAuth `code` for a user access token and return an Octokit 62 + * authenticated as that user. 63 + */ 64 + export async function userOctokitFromCode(code: string): Promise<UserOctokit> { 65 + const app = requireOAuthApp() 66 + return app.oauth.getUserOctokit({ code }) 67 + } 68 + 69 + /** 70 + * True if the authenticated user administers `installationId`. The 71 + * user-to-server `GET /user/installations` endpoint only returns installations 72 + * the user can administer, so membership in the list is the ownership proof. 73 + */ 74 + export async function userAdministersInstallation( 75 + userOctokit: UserOctokit, 76 + installationId: number, 77 + ): Promise<boolean> { 78 + // A user with >100 app installations is implausible here, so one page is 79 + // enough; `/user/installations` only lists installs the user administers. 80 + const { data } = await userOctokit.request('GET /user/installations', { per_page: 100 }) 81 + return data.installations.some(install => install.id === installationId) 82 + } 83 + 84 + /** Resolve an installation's account login (for display on the connect page). */ 85 + export async function installationAccountLogin(installationId: number): Promise<string | null> { 86 + const app = useApp() 87 + try { 88 + const { data } = await app.octokit.request('GET /app/installations/{installation_id}', { 89 + installation_id: installationId, 90 + }) 91 + const account = data.account 92 + if (account && 'login' in account) return account.login 93 + return null 94 + } 95 + catch { 96 + return null 97 + } 26 98 } 27 99 28 100 /** Test hook. */
+48
server/utils/install-ownership.ts
··· 1 + import type { H3Event } from 'h3' 2 + import { sessionConfig } from './server-session' 3 + 4 + /** 5 + * Short-lived sealed cookie proving the current browser completed GitHub 6 + * user-OAuth and was confirmed as an administrator of a specific installation. 7 + * 8 + * The atproto callback checks this before binding a DID to an installation, so 9 + * a connecting user can't claim an installation they don't actually control by 10 + * crafting `/connect?installation_id=<victim>`. Reuses the session password 11 + * for sealing; a distinct cookie name and a 15-minute TTL keep it scoped to a 12 + * single connect flow. 13 + */ 14 + interface InstallOwnershipData { 15 + installationId: number 16 + verifiedAt: number 17 + } 18 + 19 + const COOKIE_NAME = 'synchub-install-ownership' 20 + const TTL_SECONDS = 15 * 60 21 + 22 + function ownershipConfig() { 23 + return { ...sessionConfig(), name: COOKIE_NAME, maxAge: TTL_SECONDS } 24 + } 25 + 26 + export async function markInstallOwned(event: H3Event, installationId: number): Promise<void> { 27 + const session = await useSession<InstallOwnershipData>(event, ownershipConfig()) 28 + await session.update({ installationId, verifiedAt: Date.now() }) 29 + } 30 + 31 + /** 32 + * Return true if the current browser proved ownership of `installationId` 33 + * within the TTL. Does not clear the cookie; the caller clears it after a 34 + * successful bind so it can't be replayed. 35 + */ 36 + export async function hasVerifiedInstall(event: H3Event, installationId: number): Promise<boolean> { 37 + const session = await useSession<InstallOwnershipData>(event, ownershipConfig()) 38 + const { installationId: owned, verifiedAt } = session.data 39 + if (typeof owned !== 'number' || typeof verifiedAt !== 'number') return false 40 + if (owned !== installationId) return false 41 + if (Date.now() - verifiedAt > TTL_SECONDS * 1000) return false 42 + return true 43 + } 44 + 45 + export async function clearInstallOwnership(event: H3Event): Promise<void> { 46 + const session = await useSession<InstallOwnershipData>(event, ownershipConfig()) 47 + await session.clear() 48 + }
+26
test/unit/github-ownership.spec.ts
··· 1 + import { describe, expect, it, vi } from 'vitest' 2 + import { userAdministersInstallation } from '../../server/utils/github-app' 3 + import type { UserOctokit } from '../../server/utils/github-app' 4 + 5 + function octokitReturning(ids: number[]): UserOctokit { 6 + const request = vi.fn(async () => ({ data: { installations: ids.map(id => ({ id })) } })) 7 + // eslint-disable-next-line ts/no-unsafe-type-assertion -- only `request` is exercised 8 + return { request } as unknown as UserOctokit 9 + } 10 + 11 + describe('userAdministersInstallation', () => { 12 + it('returns true when the installation is in the user list', async () => { 13 + const octokit = octokitReturning([10, 137556633, 42]) 14 + expect(await userAdministersInstallation(octokit, 137556633)).toBe(true) 15 + }) 16 + 17 + it('returns false when the installation is absent', async () => { 18 + const octokit = octokitReturning([10, 42]) 19 + expect(await userAdministersInstallation(octokit, 137556633)).toBe(false) 20 + }) 21 + 22 + it('returns false for an empty installation list', async () => { 23 + const octokit = octokitReturning([]) 24 + expect(await userAdministersInstallation(octokit, 1)).toBe(false) 25 + }) 26 + })
+2 -2
test/unit/tangled-pubkey.spec.ts
··· 347 347 await revokeKeyForInstallationDid(1, 'did:plc:1') 348 348 349 349 expect(restoreMock).toHaveBeenCalledWith('did:plc:1') 350 - const del = deleteRecordMock.mock.calls[0]![0] 350 + const del = deleteRecordMock.mock.calls[0][0] 351 351 expect(del.repo).toBe('did:plc:1') 352 352 expect(del.collection).toBe('sh.tangled.publicKey') 353 353 expect(del.rkey).toBe('rkey-1') ··· 363 363 const db = useDb() 364 364 const rows = await db.select().from(sshKey) 365 365 expect(rows).toHaveLength(1) 366 - expect(rows[0]!.did).toBe('did:plc:2') 366 + expect(rows[0].did).toBe('did:plc:2') 367 367 }) 368 368 369 369 it('no-ops when no key exists for the pair', async () => {
+1 -1
test/utils/git-wire.ts
··· 71 71 const url = typeof input === 'string' ? input : input.toString() 72 72 const match = url.match(/github\.com\/(.+?)\.git\/(info\/refs|git-upload-pack)/) 73 73 if (!match) throw new Error(`fakeGithubFetch: unexpected url ${url}`) 74 - const repoPath = repos.get(match[1]!) 74 + const repoPath = repos.get(match[1]) 75 75 if (!repoPath) return new Response(null, { status: 404, statusText: 'Not Found' }) 76 76 77 77 if (match[2] === 'info/refs') {
+5
vite.config.ts
··· 5 5 const rootDir = import.meta.dirname 6 6 7 7 export default defineConfig({ 8 + resolve: { 9 + alias: { 10 + '#server': `${rootDir}/server`, 11 + }, 12 + }, 8 13 lint: { 9 14 plugins: ['typescript', 'vue', 'oxc', 'unicorn', 'vitest'], 10 15 jsPlugins: ['@stylistic/eslint-plugin'],