Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit7746deeunknown_key

feat(git-auth): app-bot push auth via ghi_ install tokens (move #4)

feat(git-auth): app-bot push auth via ghi_ install tokens (move #4)

Closes the fourth strategic move from docs/STRATEGY.md §3. App-bot
identities now show up in the git-push auth path; before this
commit, ghi_ install tokens resolved to nothing (the bot row in
app_bots had no users.id link).

Schema strategy — no migration:
- src/lib/marketplace.ts createApp() now also inserts a synthetic
  users row alongside the app_bots row. Username matches the bot
  name (`<slug>[bot]`); email is `<bot>@gluecron.local`; password
  hash is a non-functional `!bot:<hex>` so login flows reject it.
  All bot email-notification prefs default off. onConflictDoNothing
  keeps re-runs idempotent.
- Legacy bots created before this commit have no synthetic users
  row; the install-token resolver fails soft to null on lookup so
  those callers stay anonymous (current behaviour) rather than
  breaking. Newly-created apps get the back-fill automatically.

Auth resolver:
- src/lib/git-push-auth.ts gains `resolveByInstallToken(token)`:
    1. sha256-hash the bearer
    2. look up app_install_tokens + join app_installations + app_bots
       in one round-trip
    3. reject revoked / expired / suspendedAt / uninstalledAt
    4. find the synthetic users row by bot username
    5. return { userId, username, source: "install_token" } or null
- ResolvedPusher.source type widened to "pat" | "oauth" | "install_token".
- resolvePusher checks ghi_ alongside glc_ + glct_ for both Bearer
  and Basic schemes (git CLI puts the token in the password field).

Tests: +1 (ghi_ install token unknown-token → null on Bearer + Basic
schemes). Total suite 1214 pass / 8 skip / 0 fail (was 1213 / 8 / 0).

Authentication ≠ authorization — bots get identity for the audit
log + protected-tag enforcement, but they still need to be repo
owner or future tag-admin to bypass protected refs. Granting bots
collaborator access on specific repos is a follow-up and orthogonal
to this commit.

https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU
Claude committed on April 30, 2026Parent: 2c2163e
3 files changed+112177746deedfde24e39cc1d168b2b97d622f6b08075
3 changed files+112−17
Modifiedsrc/__tests__/git-push-auth.test.ts+9−0View fileUnifiedSplit
107107 it("returns null on a Basic with an empty secret", async () => {
108108 expect(await resolvePusher(`Basic ${b64("alice:")}`)).toBeNull();
109109 });
110
111 it("returns null for a ghi_ install token that doesn't exist in DB", async () => {
112 // Real install tokens are sha256-hashed in app_install_tokens; an
113 // unknown token should fail soft to anonymous, never throw.
114 expect(await resolvePusher("Bearer ghi_definitely-not-real")).toBeNull();
115 expect(
116 await resolvePusher(`Basic ${b64("x-access-token:ghi_definitely-not-real")}`)
117 ).toBeNull();
118 });
110119});
Modifiedsrc/lib/git-push-auth.ts+71−16View fileUnifiedSplit
33 *
44 * Smart-HTTP push doesn't ride the cookie/session middleware (git CLI
55 * doesn't keep cookies). It sends `Authorization: Basic <b64(user:secret)>`
6 * or `Authorization: Bearer <token>`. We accept two secret shapes:
6 * or `Authorization: Bearer <token>`. We accept three secret shapes:
77 *
88 * - `glc_*` — personal access token (Block C2)
99 * - `glct_*`OAuth access token (Block B6)
10 *
11 * Installation tokens (`ghi_*`, Block H2) are deliberately not handled
12 * here yet: app bots are stored in `app_bots` with their own `username`
13 * but no link to a `users.id` row, so they can't currently own a push
14 * decision in the protected-tag path. Adding bot identities to the auth
15 * decision is future work — the resolver returns null for `ghi_*` and the
16 * push falls back to anonymous, which is rejected by every protected ref.
10 * - `ghi_*` — installation token for an app-bot (Block H2). The
11 * token resolves to the synthetic users row created by
12 * `createApp(...)` (`<slug>[bot]` username). Legacy bots
13 * created before the synthetic-user back-fill landed
14 * fail soft and resolve as anonymous.
1715 *
1816 * Best-effort only: returns null (anonymous) on any failure. The caller
1917 * must decide what anonymous can do (current policy: anonymous can
2220
2321import { eq } from "drizzle-orm";
2422import { db } from "../db";
25import { users, apiTokens, oauthAccessTokens } from "../db/schema";
23import {
24 users,
25 apiTokens,
26 oauthAccessTokens,
27 appInstallTokens,
28 appInstallations,
29 appBots,
30} from "../db/schema";
2631import { sha256Hex } from "./oauth";
2732
2833export type ResolvedPusher = {
2934 userId: string;
3035 username: string;
31 source: "pat" | "oauth";
36 source: "pat" | "oauth" | "install_token";
3237};
3338
3439/** Decode a `Basic` auth header → `{user, secret}` or null on malformed. */
110115 }
111116}
112117
118async function resolveByInstallToken(
119 token: string
120): Promise<ResolvedPusher | null> {
121 if (!token.startsWith("ghi_")) return null;
122 try {
123 const hash = await sha256Hex(token);
124 // Look up the install token + its installation + the app's bot
125 // username in one round-trip via the join shape.
126 const [row] = await db
127 .select({
128 tokenId: appInstallTokens.id,
129 revokedAt: appInstallTokens.revokedAt,
130 expiresAt: appInstallTokens.expiresAt,
131 suspendedAt: appInstallations.suspendedAt,
132 uninstalledAt: appInstallations.uninstalledAt,
133 botUsername: appBots.username,
134 })
135 .from(appInstallTokens)
136 .innerJoin(
137 appInstallations,
138 eq(appInstallTokens.installationId, appInstallations.id)
139 )
140 .innerJoin(appBots, eq(appBots.appId, appInstallations.appId))
141 .where(eq(appInstallTokens.tokenHash, hash))
142 .limit(1);
143 if (!row) return null;
144 if (row.revokedAt) return null;
145 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
146 if (row.suspendedAt) return null;
147 if (row.uninstalledAt) return null;
148
149 // Find the synthetic users row that createApp inserts for the bot.
150 // Legacy bots (created before the back-fill landed) won't have one;
151 // those resolve as anonymous, which fails closed on every protected
152 // ref but doesn't break public-repo writes.
153 const [u] = await db
154 .select({ id: users.id, username: users.username })
155 .from(users)
156 .where(eq(users.username, row.botUsername))
157 .limit(1);
158 if (!u) return null;
159 return { userId: u.id, username: u.username, source: "install_token" };
160 } catch {
161 return null;
162 }
163}
164
113165/**
114 * Resolve the pusher from an Authorization header. Tries Bearer (PAT or
115 * OAuth) then Basic (where the secret in the password field can also be
116 * a PAT or OAuth token — git CLI sends `git config credential.helper`
117 * output as username + password).
166 * Resolve the pusher from an Authorization header. Tries Bearer (PAT,
167 * OAuth, or install token) then Basic (where the secret in the password
168 * field can also be any of the three). git CLI sends
169 * `credential.helper` output as username + password; users typically
170 * paste the token as the password.
118171 */
119172export async function resolvePusher(
120173 authHeader: string | null | undefined
125178 if (bearer) {
126179 return (
127180 (await resolveByPat(bearer)) ||
128 (await resolveByOauth(bearer))
181 (await resolveByOauth(bearer)) ||
182 (await resolveByInstallToken(bearer))
129183 );
130184 }
131185
134188 const secret = basic.secret;
135189 return (
136190 (await resolveByPat(secret)) ||
137 (await resolveByOauth(secret))
191 (await resolveByOauth(secret)) ||
192 (await resolveByInstallToken(secret))
138193 );
139194 }
140195
Modifiedsrc/lib/marketplace.ts+32−1View fileUnifiedSplit
211211 .returning();
212212 if (!row) return null;
213213 // Create matching bot account
214 const botUname = botUsername(slug);
214215 await db.insert(appBots).values({
215216 appId: row.id,
216 username: botUsername(slug),
217 username: botUname,
217218 displayName: `${args.name} (bot)`,
218219 avatarUrl: args.iconUrl,
219220 });
221 // Create a synthetic users row so install-token push auth has a real
222 // users.id to attribute to. Email + passwordHash are deliberately
223 // unusable so bots cannot log in via password — auth is install-token
224 // only via Authorization: Bearer ghi_*. Idempotent: onConflictDoNothing
225 // keeps re-runs safe (e.g. if the apps insert succeeds but the bot
226 // row had to be retried).
227 const botEmail = `${botUname}@gluecron.local`;
228 try {
229 await db
230 .insert(users)
231 .values({
232 username: botUname,
233 email: botEmail,
234 displayName: `${args.name} (bot)`,
235 // Random non-functional hash. Anyone trying to "guess" it
236 // would still fail bcrypt-compare since no password makes it.
237 passwordHash: `!bot:${randomBytes(24).toString("hex")}`,
238 avatarUrl: args.iconUrl,
239 // Bots opt out of all email notifications by default.
240 notifyEmailOnMention: false,
241 notifyEmailOnAssign: false,
242 notifyEmailOnGateFail: false,
243 })
244 .onConflictDoNothing?.();
245 } catch {
246 // Bot user already exists or onConflictDoNothing isn't supported on
247 // this driver — either way, the install-token resolver will fail
248 // soft on lookup and treat the call as anonymous. Do not let this
249 // block app creation.
250 }
220251 return row;
221252 } catch (err: any) {
222253 if (String(err?.message || "").includes("duplicate")) continue;
223254