From 9a1d8991a890501b792d83b57a6151ad47e5f287 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Tue, 7 Jul 2026 23:58:28 +0200 Subject: [PATCH 1/4] feat: accept invites via OAuth (Google/GitHub), not just passkeys An invited user can complete their invite by signing in with a configured OAuth provider. The invite token is carried through the OAuth state, and the callback completes the invite (creating the user with the invited role and linking the account) only when the provider-verified email matches the invited address. The invite-accept page renders the configured providers under an 'Or continue with' divider. --- .changeset/oauth-acceptable-invites.md | 7 + .../admin/src/components/InviteAcceptPage.tsx | 32 +++++ .../admin/src/lib/auth-provider-context.tsx | 8 +- packages/auth/src/index.ts | 1 + packages/auth/src/oauth/consumer.ts | 89 +++++++++++- packages/auth/src/oauth/types.ts | 7 + .../astro/routes/api/auth/oauth/[provider].ts | 8 +- .../api/auth/oauth/[provider]/callback.ts | 6 + packages/core/src/auth/oauth-state-store.ts | 3 + .../core/src/auth/providers/github-admin.tsx | 11 +- .../core/src/auth/providers/google-admin.tsx | 11 +- .../core/tests/unit/auth/oauth-invite.test.ts | 127 ++++++++++++++++++ 12 files changed, 294 insertions(+), 16 deletions(-) create mode 100644 .changeset/oauth-acceptable-invites.md create mode 100644 packages/core/tests/unit/auth/oauth-invite.test.ts diff --git a/.changeset/oauth-acceptable-invites.md b/.changeset/oauth-acceptable-invites.md new file mode 100644 index 0000000000..e17e373af2 --- /dev/null +++ b/.changeset/oauth-acceptable-invites.md @@ -0,0 +1,7 @@ +--- +"@emdash-cms/auth": minor +"emdash": minor +"@emdash-cms/admin": minor +--- + +Add OAuth-acceptable invites. An invited user can now accept their invite by signing in with a configured OAuth provider (Google/GitHub) instead of only registering a passkey. The invite token is carried through the OAuth `state`, and the callback completes the invite (creating the user with the invited role and linking the OAuth account) only when the provider-verified email matches the invited address, so a login for a different account cannot consume someone else's invite. The invite-accept page now shows the configured providers under an "Or continue with" divider. diff --git a/packages/admin/src/components/InviteAcceptPage.tsx b/packages/admin/src/components/InviteAcceptPage.tsx index 2c86a5563f..4ddd13b84d 100644 --- a/packages/admin/src/components/InviteAcceptPage.tsx +++ b/packages/admin/src/components/InviteAcceptPage.tsx @@ -10,6 +10,7 @@ import { useSearch } from "@tanstack/react-router"; import * as React from "react"; import { validateInviteToken, type InviteVerifyResult } from "../lib/api"; +import { useAuthProviderList } from "../lib/auth-provider-context"; import { PasskeyRegistration } from "./auth/PasskeyRegistration"; import { LogoLockup } from "./Logo.js"; import { RouterLinkButton } from "./RouterLinkButton.js"; @@ -28,6 +29,7 @@ function handleInviteSuccess() { function RegisterStep({ inviteData, token }: RegisterStepProps) { const { t } = useLingui(); const [name, setName] = React.useState(""); + const buttonProviders = useAuthProviderList().filter((p) => p.LoginButton); return (
@@ -82,6 +84,36 @@ function RegisterStep({ inviteData, token }: RegisterStepProps) { additionalData={{ token, name: name || undefined }} />
+ + {buttonProviders.length > 0 && ( + <> + {/* Divider */} +
+
+ +
+
+ {t`Or continue with`} +
+
+ + {/* Accept the invite via an OAuth provider. The button carries the + invite token; the callback only completes the invite when the + provider-verified email matches the invited address. */} +
+ {buttonProviders.map((provider) => { + const Btn = provider.LoginButton!; + return ( +
+ +
+ ); + })} +
+ + )} ); } diff --git a/packages/admin/src/lib/auth-provider-context.tsx b/packages/admin/src/lib/auth-provider-context.tsx index 539be0231c..54840f9d30 100644 --- a/packages/admin/src/lib/auth-provider-context.tsx +++ b/packages/admin/src/lib/auth-provider-context.tsx @@ -13,8 +13,12 @@ import { createContext, useContext } from "react"; export interface AuthProviderModule { id: string; label: string; - /** Compact button for the login page (icon + label) */ - LoginButton?: React.ComponentType; + /** + * Compact button for the login page (icon + label). When rendered on the + * invite-accept page it receives the invite token so the button can start an + * invite-completing OAuth flow. + */ + LoginButton?: React.ComponentType<{ inviteToken?: string }>; /** Full form if the provider needs custom input (e.g., handle field) */ LoginForm?: React.ComponentType; /** Component for the setup wizard admin creation step */ diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 32be9e695b..58cb856ac9 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -110,6 +110,7 @@ export { createAuthorizationUrl, handleOAuthCallback, findOrCreateOAuthUser, + acceptInviteViaOAuth, OAuthError, github, google, diff --git a/packages/auth/src/oauth/consumer.ts b/packages/auth/src/oauth/consumer.ts index 524f7c94f2..32cdb1a390 100644 --- a/packages/auth/src/oauth/consumer.ts +++ b/packages/auth/src/oauth/consumer.ts @@ -6,6 +6,7 @@ import { sha256 } from "@oslojs/crypto/sha2"; import { encodeBase64urlNoPadding } from "@oslojs/encoding"; import { z } from "zod"; +import { completeInvite, InviteError, validateInvite } from "../invite.js"; import type { AuthAdapter, User, RoleLevel } from "../types.js"; import { github, fetchGitHubEmail } from "./providers/github.js"; import { google } from "./providers/google.js"; @@ -32,6 +33,10 @@ export async function createAuthorizationUrl( config: OAuthConsumerConfig, providerName: "github" | "google", stateStore: StateStore, + options?: { + /** When set, this flow accepts an invite and the callback completes it. */ + inviteToken?: string; + }, ): Promise<{ url: string; state: string }> { const providerConfig = config.providers[providerName]; if (!providerConfig) { @@ -54,6 +59,7 @@ export async function createAuthorizationUrl( provider: providerName, redirectUri, codeVerifier, + ...(options?.inviteToken ? { inviteToken: options.inviteToken } : {}), }); // Build authorization URL @@ -110,10 +116,89 @@ export async function handleOAuthCallback( // Fetch user profile const profile = await fetchProfile(provider, tokens.accessToken, providerName); + // When the flow carried an invite token, complete the invite instead of + // applying the self-signup policy. + if (storedState.inviteToken) { + return acceptInviteViaOAuth(adapter, providerName, profile, storedState.inviteToken); + } + // Find or create user return findOrCreateOAuthUser(adapter, providerName, profile, config.canSelfSignup); } +/** + * Complete an invite using an OAuth identity. + * + * The invite is only accepted when the provider has verified the email AND it + * matches the invited address (case-insensitive), so a login for a different + * account cannot consume someone else's invite. On success the user is created + * with the invited role and the OAuth account is linked. + */ +export async function acceptInviteViaOAuth( + adapter: AuthAdapter, + providerName: string, + profile: OAuthProfile, + inviteToken: string, +): Promise { + let invite: { email: string; role: RoleLevel }; + try { + invite = await validateInvite(adapter, inviteToken); + } catch (error) { + if (error instanceof InviteError) { + throw new OAuthError("invite_invalid", error.message); + } + throw error; + } + + if (!profile.emailVerified) { + throw new OAuthError( + "invite_email_mismatch", + "Cannot accept invite: email not verified by provider", + ); + } + + if (profile.email.toLowerCase() !== invite.email.toLowerCase()) { + throw new OAuthError( + "invite_email_mismatch", + "This invite was sent to a different email address than your account.", + ); + } + + // Already linked (e.g. a retried callback): return the existing user. + const existingAccount = await adapter.getOAuthAccount(providerName, profile.id); + if (existingAccount) { + const user = await adapter.getUserById(existingAccount.userId); + if (!user) { + throw new OAuthError("user_not_found", "Linked user not found"); + } + return user; + } + + // The invited email already belongs to a user (invite already accepted, or a + // pre-existing account): link the OAuth account rather than failing. + const existingUser = await adapter.getUserByEmail(profile.email); + if (existingUser) { + await adapter.createOAuthAccount({ + provider: providerName, + providerAccountId: profile.id, + userId: existingUser.id, + }); + return existingUser; + } + + // Consume the invite: create the user with the invited role, then link. + const user = await completeInvite(adapter, inviteToken, { + name: profile.name ?? undefined, + avatarUrl: profile.avatarUrl ?? undefined, + }); + await adapter.createOAuthAccount({ + provider: providerName, + providerAccountId: profile.id, + userId: user.id, + }); + return user; +} + /** * Exchange authorization code for tokens */ @@ -331,7 +416,9 @@ export class OAuthError extends Error { | "token_exchange_failed" | "profile_fetch_failed" | "user_not_found" - | "signup_not_allowed", + | "signup_not_allowed" + | "invite_invalid" + | "invite_email_mismatch", message: string, ) { super(message); diff --git a/packages/auth/src/oauth/types.ts b/packages/auth/src/oauth/types.ts index 08c91558d3..de33a87d0f 100644 --- a/packages/auth/src/oauth/types.ts +++ b/packages/auth/src/oauth/types.ts @@ -33,4 +33,11 @@ export interface OAuthState { redirectUri: string; codeVerifier?: string; // For PKCE nonce?: string; + /** + * When present, this OAuth flow is accepting an invite. The callback + * completes the invite (creating the user with the invited role and linking + * the OAuth account) instead of falling back to the self-signup policy, but + * only when the provider-verified email matches the invited address. + */ + inviteToken?: string; } diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts index a9706888fc..56c849c2e4 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts @@ -114,7 +114,13 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => { const stateStore = createOAuthStateStore(emdash.db); - const { url: authUrl } = await createAuthorizationUrl(config, provider, stateStore); + // When the flow starts from an invite link, carry the invite token so the + // callback can complete the invite for a matching, verified email. + const inviteToken = url.searchParams.get("invite") ?? undefined; + + const { url: authUrl } = await createAuthorizationUrl(config, provider, stateStore, { + inviteToken, + }); return redirect(authUrl); } catch (error) { diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts index e49c2acdc3..9618110f36 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts @@ -217,6 +217,12 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect case "signup_not_allowed": message = "Self-signup is not allowed for your email. Please contact an administrator."; break; + case "invite_invalid": + message = "This invite link is invalid or has expired. Please ask for a new one."; + break; + case "invite_email_mismatch": + message = "This invite was sent to a different email address than your account."; + break; case "user_not_found": message = "Your account was not found. It may have been deleted."; break; diff --git a/packages/core/src/auth/oauth-state-store.ts b/packages/core/src/auth/oauth-state-store.ts index 43d8915e00..97cc8ac516 100644 --- a/packages/core/src/auth/oauth-state-store.ts +++ b/packages/core/src/auth/oauth-state-store.ts @@ -79,6 +79,9 @@ export function createOAuthStateStore(db: Kysely): StateStore { if ("nonce" in parsed && typeof parsed.nonce === "string") { oauthState.nonce = parsed.nonce; } + if ("inviteToken" in parsed && typeof parsed.inviteToken === "string") { + oauthState.inviteToken = parsed.inviteToken; + } return oauthState; } catch { return null; diff --git a/packages/core/src/auth/providers/github-admin.tsx b/packages/core/src/auth/providers/github-admin.tsx index 79283bd69e..04fab61fcc 100644 --- a/packages/core/src/auth/providers/github-admin.tsx +++ b/packages/core/src/auth/providers/github-admin.tsx @@ -15,13 +15,12 @@ function GitHubIcon({ className }: { className?: string }) { ); } -export function LoginButton() { +export function LoginButton({ inviteToken }: { inviteToken?: string } = {}) { + const href = inviteToken + ? `/_emdash/api/auth/oauth/github?invite=${encodeURIComponent(inviteToken)}` + : "/_emdash/api/auth/oauth/github"; return ( - + GitHub diff --git a/packages/core/src/auth/providers/google-admin.tsx b/packages/core/src/auth/providers/google-admin.tsx index 5284f09993..e04c6e06d8 100644 --- a/packages/core/src/auth/providers/google-admin.tsx +++ b/packages/core/src/auth/providers/google-admin.tsx @@ -30,13 +30,12 @@ function GoogleIcon({ className }: { className?: string }) { ); } -export function LoginButton() { +export function LoginButton({ inviteToken }: { inviteToken?: string } = {}) { + const href = inviteToken + ? `/_emdash/api/auth/oauth/google?invite=${encodeURIComponent(inviteToken)}` + : "/_emdash/api/auth/oauth/google"; return ( - + Google diff --git a/packages/core/tests/unit/auth/oauth-invite.test.ts b/packages/core/tests/unit/auth/oauth-invite.test.ts new file mode 100644 index 0000000000..d84201e52f --- /dev/null +++ b/packages/core/tests/unit/auth/oauth-invite.test.ts @@ -0,0 +1,127 @@ +import type { AuthAdapter, OAuthProfile } from "@emdash-cms/auth"; +import { Role, acceptInviteViaOAuth, createInviteToken, OAuthError } from "@emdash-cms/auth"; +import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; +import type { Kysely } from "kysely"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +const TOKEN_EXTRACT_REGEX = /token=([a-zA-Z0-9_-]+)/; + +function makeProfile(overrides: Partial = {}): OAuthProfile { + return { + id: "google-123", + email: "invitee@example.com", + name: "Invitee", + avatarUrl: null, + emailVerified: true, + ...overrides, + }; +} + +describe("acceptInviteViaOAuth", () => { + let db: Kysely; + let adapter: AuthAdapter; + let adminId: string; + + beforeEach(async () => { + db = await setupTestDatabase(); + adapter = createKyselyAdapter(db); + const admin = await adapter.createUser({ + email: "admin@example.com", + name: "Admin", + role: Role.ADMIN, + emailVerified: true, + }); + adminId = admin.id; + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + async function invite(email: string, role: number = Role.AUTHOR): Promise { + const { url } = await createInviteToken( + { baseUrl: "https://example.com/_emdash" }, + adapter, + email, + role, + adminId, + ); + const match = url.match(TOKEN_EXTRACT_REGEX); + if (!match) throw new Error("could not extract invite token"); + return match[1]; + } + + it("completes the invite, sets the invited role, links the account, and consumes the token", async () => { + const token = await invite("invitee@example.com", Role.EDITOR); + + const user = await acceptInviteViaOAuth(adapter, "google", makeProfile(), token); + + expect(user.email).toBe("invitee@example.com"); + expect(user.role).toBe(Role.EDITOR); + + const account = await adapter.getOAuthAccount("google", "google-123"); + expect(account?.userId).toBe(user.id); + + // Single-use: the consumed token can no longer be replayed. + await expect( + acceptInviteViaOAuth(adapter, "google", makeProfile({ id: "google-999" }), token), + ).rejects.toMatchObject({ code: "invite_invalid" }); + }); + + it("matches the invited email case-insensitively", async () => { + const token = await invite("Invitee@Example.com"); + + // Invited as "Invitee@Example.com", provider reports "invitee@example.com": + // the differing case still completes the invite (email is normalized on store). + const user = await acceptInviteViaOAuth( + adapter, + "google", + makeProfile({ email: "invitee@example.com" }), + token, + ); + + expect(user.email.toLowerCase()).toBe("invitee@example.com"); + expect(await adapter.getOAuthAccount("google", "google-123")).not.toBeNull(); + }); + + it("rejects when the OAuth email does not match the invite", async () => { + const token = await invite("invitee@example.com"); + + await expect( + acceptInviteViaOAuth( + adapter, + "google", + makeProfile({ email: "someone-else@example.com" }), + token, + ), + ).rejects.toMatchObject({ code: "invite_email_mismatch" }); + + // No account was created for the mismatched email. + expect(await adapter.getUserByEmail("someone-else@example.com")).toBeNull(); + }); + + it("rejects when the provider has not verified the email", async () => { + const token = await invite("invitee@example.com"); + + await expect( + acceptInviteViaOAuth(adapter, "google", makeProfile({ emailVerified: false }), token), + ).rejects.toMatchObject({ code: "invite_email_mismatch" }); + + expect(await adapter.getUserByEmail("invitee@example.com")).toBeNull(); + }); + + it("rejects an invalid or unknown invite token", async () => { + await expect( + acceptInviteViaOAuth(adapter, "google", makeProfile(), "not-a-real-token"), + ).rejects.toMatchObject({ code: "invite_invalid" }); + }); + + it("throws OAuthError (not InviteError) so the callback maps it to a message", async () => { + await expect( + acceptInviteViaOAuth(adapter, "google", makeProfile(), "not-a-real-token"), + ).rejects.toBeInstanceOf(OAuthError); + }); +}); From 84a1b0306698b873ea15d4d0ff8c0fdadacbe8f0 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 8 Jul 2026 02:24:37 +0200 Subject: [PATCH 2/4] fix: consume invite token when linking existing account; distinct unverified-email error Addresses review on #1868: - acceptInviteViaOAuth now deletes the invite token in the existing-user and existing-account branches, preserving the single-use guarantee. - Unverified provider email throws a new invite_email_unverified code with its own callback message, instead of the misleading email-mismatch message. --- packages/auth/src/oauth/consumer.ts | 13 +++++++--- .../api/auth/oauth/[provider]/callback.ts | 4 +++ .../core/tests/unit/auth/oauth-invite.test.ts | 25 +++++++++++++++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/auth/src/oauth/consumer.ts b/packages/auth/src/oauth/consumer.ts index 32cdb1a390..4047be3046 100644 --- a/packages/auth/src/oauth/consumer.ts +++ b/packages/auth/src/oauth/consumer.ts @@ -7,6 +7,7 @@ import { encodeBase64urlNoPadding } from "@oslojs/encoding"; import { z } from "zod"; import { completeInvite, InviteError, validateInvite } from "../invite.js"; +import { hashToken } from "../tokens.js"; import type { AuthAdapter, User, RoleLevel } from "../types.js"; import { github, fetchGitHubEmail } from "./providers/github.js"; import { google } from "./providers/google.js"; @@ -152,7 +153,7 @@ export async function acceptInviteViaOAuth( if (!profile.emailVerified) { throw new OAuthError( - "invite_email_mismatch", + "invite_email_unverified", "Cannot accept invite: email not verified by provider", ); } @@ -164,18 +165,20 @@ export async function acceptInviteViaOAuth( ); } - // Already linked (e.g. a retried callback): return the existing user. + // Already linked (e.g. a retried callback): consume the invite and return. const existingAccount = await adapter.getOAuthAccount(providerName, profile.id); if (existingAccount) { const user = await adapter.getUserById(existingAccount.userId); if (!user) { throw new OAuthError("user_not_found", "Linked user not found"); } + await adapter.deleteToken(hashToken(inviteToken)); return user; } // The invited email already belongs to a user (invite already accepted, or a - // pre-existing account): link the OAuth account rather than failing. + // pre-existing account): link the OAuth account and consume the invite rather + // than failing, so the single-use token cannot be replayed. const existingUser = await adapter.getUserByEmail(profile.email); if (existingUser) { await adapter.createOAuthAccount({ @@ -183,6 +186,7 @@ export async function acceptInviteViaOAuth( providerAccountId: profile.id, userId: existingUser.id, }); + await adapter.deleteToken(hashToken(inviteToken)); return existingUser; } @@ -418,7 +422,8 @@ export class OAuthError extends Error { | "user_not_found" | "signup_not_allowed" | "invite_invalid" - | "invite_email_mismatch", + | "invite_email_mismatch" + | "invite_email_unverified", message: string, ) { super(message); diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts index 9618110f36..f178f14efe 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts @@ -223,6 +223,10 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect case "invite_email_mismatch": message = "This invite was sent to a different email address than your account."; break; + case "invite_email_unverified": + message = + "Your account's email is not verified by the provider. Please verify it and try again."; + break; case "user_not_found": message = "Your account was not found. It may have been deleted."; break; diff --git a/packages/core/tests/unit/auth/oauth-invite.test.ts b/packages/core/tests/unit/auth/oauth-invite.test.ts index d84201e52f..ec46fd54ad 100644 --- a/packages/core/tests/unit/auth/oauth-invite.test.ts +++ b/packages/core/tests/unit/auth/oauth-invite.test.ts @@ -103,16 +103,37 @@ describe("acceptInviteViaOAuth", () => { expect(await adapter.getUserByEmail("someone-else@example.com")).toBeNull(); }); - it("rejects when the provider has not verified the email", async () => { + it("rejects with a distinct code when the provider has not verified the email", async () => { const token = await invite("invitee@example.com"); await expect( acceptInviteViaOAuth(adapter, "google", makeProfile({ emailVerified: false }), token), - ).rejects.toMatchObject({ code: "invite_email_mismatch" }); + ).rejects.toMatchObject({ code: "invite_email_unverified" }); expect(await adapter.getUserByEmail("invitee@example.com")).toBeNull(); }); + it("consumes the invite token when linking to a pre-existing account", async () => { + const token = await invite("invitee@example.com"); + // A user with the invited email is created another way after the invite is + // issued (e.g. an admin-created user or a passkey-accept race). + await adapter.createUser({ + email: "invitee@example.com", + name: "Invitee", + role: Role.AUTHOR, + emailVerified: true, + }); + + const user = await acceptInviteViaOAuth(adapter, "google", makeProfile(), token); + expect(user.email).toBe("invitee@example.com"); + expect(await adapter.getOAuthAccount("google", "google-123")).not.toBeNull(); + + // Single-use: the linked-and-consumed token cannot be replayed. + await expect( + acceptInviteViaOAuth(adapter, "google", makeProfile({ id: "google-777" }), token), + ).rejects.toMatchObject({ code: "invite_invalid" }); + }); + it("rejects an invalid or unknown invite token", async () => { await expect( acceptInviteViaOAuth(adapter, "google", makeProfile(), "not-a-real-token"), From 5600977417cb812618b6439107e976e042325759 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 9 Jul 2026 23:01:05 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20linke?= =?UTF-8?q?d-user=20email=20guard,=20invite-token=20validation,=20changese?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - acceptInviteViaOAuth: in the already-linked branch, require the linked user's email to match the invite before consuming, so an identity whose email changed after linking can't consume someone else's invite. - oauth/[provider]: validate the ?invite= token shape/length before persisting to the (unauthenticated) OAuth state store. - changeset: rewrite as a single user-facing sentence. --- .changeset/oauth-acceptable-invites.md | 2 +- packages/auth/src/oauth/consumer.ts | 9 ++++++ .../astro/routes/api/auth/oauth/[provider].ts | 10 +++++-- .../core/tests/unit/auth/oauth-invite.test.ts | 30 +++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.changeset/oauth-acceptable-invites.md b/.changeset/oauth-acceptable-invites.md index e17e373af2..eccd8f5e72 100644 --- a/.changeset/oauth-acceptable-invites.md +++ b/.changeset/oauth-acceptable-invites.md @@ -4,4 +4,4 @@ "@emdash-cms/admin": minor --- -Add OAuth-acceptable invites. An invited user can now accept their invite by signing in with a configured OAuth provider (Google/GitHub) instead of only registering a passkey. The invite token is carried through the OAuth `state`, and the callback completes the invite (creating the user with the invited role and linking the OAuth account) only when the provider-verified email matches the invited address, so a login for a different account cannot consume someone else's invite. The invite-accept page now shows the configured providers under an "Or continue with" divider. +Invited users can now accept their invite by signing in with Google or GitHub, instead of only creating a passkey. diff --git a/packages/auth/src/oauth/consumer.ts b/packages/auth/src/oauth/consumer.ts index 4047be3046..123778eccb 100644 --- a/packages/auth/src/oauth/consumer.ts +++ b/packages/auth/src/oauth/consumer.ts @@ -172,6 +172,15 @@ export async function acceptInviteViaOAuth( if (!user) { throw new OAuthError("user_not_found", "Linked user not found"); } + // The linked user must actually own the invited email; otherwise a + // provider identity whose EmDash email was changed after linking could + // consume an invite issued to someone else. + if (user.email.toLowerCase() !== invite.email.toLowerCase()) { + throw new OAuthError( + "invite_email_mismatch", + "This invite was sent to a different email address than your account.", + ); + } await adapter.deleteToken(hashToken(inviteToken)); return user; } diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts index 56c849c2e4..b1234d2e23 100644 --- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts +++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts @@ -17,6 +17,9 @@ type ProviderName = "github" | "google"; const VALID_PROVIDERS = new Set(["github", "google"]); +/** Invite tokens are base64url; clamp shape and length before persisting to state. */ +const INVITE_TOKEN_REGEX = /^[A-Za-z0-9_-]{1,256}$/; + function isValidProvider(provider: string): provider is ProviderName { return VALID_PROVIDERS.has(provider); } @@ -115,8 +118,11 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => { const stateStore = createOAuthStateStore(emdash.db); // When the flow starts from an invite link, carry the invite token so the - // callback can complete the invite for a matching, verified email. - const inviteToken = url.searchParams.get("invite") ?? undefined; + // callback can complete the invite for a matching, verified email. Validate + // its shape/length first: this endpoint is unauthenticated, so we avoid + // persisting arbitrary or oversized values into the short-lived state store. + const rawInvite = url.searchParams.get("invite"); + const inviteToken = rawInvite && INVITE_TOKEN_REGEX.test(rawInvite) ? rawInvite : undefined; const { url: authUrl } = await createAuthorizationUrl(config, provider, stateStore, { inviteToken, diff --git a/packages/core/tests/unit/auth/oauth-invite.test.ts b/packages/core/tests/unit/auth/oauth-invite.test.ts index ec46fd54ad..0d28652a5a 100644 --- a/packages/core/tests/unit/auth/oauth-invite.test.ts +++ b/packages/core/tests/unit/auth/oauth-invite.test.ts @@ -134,6 +134,36 @@ describe("acceptInviteViaOAuth", () => { ).rejects.toMatchObject({ code: "invite_invalid" }); }); + it("rejects (and does not consume) when the already-linked account's user has a different email", async () => { + const token = await invite("invitee@example.com"); + // An OAuth identity is already linked to a user whose EmDash email differs + // from the invited address (e.g. the email was changed after linking). + const other = await adapter.createUser({ + email: "other@example.com", + name: "Other", + role: Role.AUTHOR, + emailVerified: true, + }); + await adapter.createOAuthAccount({ + provider: "google", + providerAccountId: "google-linked", + userId: other.id, + }); + + await expect( + acceptInviteViaOAuth( + adapter, + "google", + makeProfile({ id: "google-linked", email: "invitee@example.com" }), + token, + ), + ).rejects.toMatchObject({ code: "invite_email_mismatch" }); + + // The invite was not consumed: a fresh, correctly-matched identity still works. + const user = await acceptInviteViaOAuth(adapter, "google", makeProfile(), token); + expect(user.email).toBe("invitee@example.com"); + }); + it("rejects an invalid or unknown invite token", async () => { await expect( acceptInviteViaOAuth(adapter, "google", makeProfile(), "not-a-real-token"), From 2742e32f81879fe62d5960d56bc8d7cb52264be7 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Fri, 10 Jul 2026 13:29:20 +0000 Subject: [PATCH 4/4] ci: update query-count snapshots --- scripts/query-counts.queries.d1.json | 6 ++++-- scripts/query-counts.queries.sqlite.json | 6 ++++-- scripts/query-counts.snapshot.d1.json | 4 ++-- scripts/query-counts.snapshot.sqlite.json | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/query-counts.queries.d1.json b/scripts/query-counts.queries.d1.json index c0ea868a41..8817345ae2 100644 --- a/scripts/query-counts.queries.d1.json +++ b/scripts/query-counts.queries.d1.json @@ -219,6 +219,7 @@ }, "GET /search (cold)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"c\".\"slug\" as \"collection_slug\" from \"_emdash_fields\" as \"f\" inner join \"_emdash_collections\" as \"c\" on \"c\".\"id\" = \"f\".\"collection_id\" where \"f\".\"slug\" = ?": 1, "select \"id\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"name\", \"value\" from \"options\" where \"name\" in (...)": 2, "select \"name\", \"value\" from \"options\" where name LIKE ? ESCAPE '\\'": 1, @@ -232,7 +233,7 @@ "select * from \"_emdash_migrations\" limit ?": 1, "select * from \"_emdash_redirects\" where \"enabled\" = ?": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.id, c.slug, c.locale, c.title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, + "SELECT c.id, c.slug, c.locale, c.title as title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, "select count(*) as \"count\" from \"_emdash_collections\"": 1, "SELECT COUNT(*) as count FROM \"_emdash_migrations\"": 1, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?": 1, @@ -240,6 +241,7 @@ }, "GET /search (warm)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"c\".\"slug\" as \"collection_slug\" from \"_emdash_fields\" as \"f\" inner join \"_emdash_collections\" as \"c\" on \"c\".\"id\" = \"f\".\"collection_id\" where \"f\".\"slug\" = ?": 1, "select \"id\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"search_config\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"slug\" from \"_emdash_fields\" where \"collection_id\" = ? and \"searchable\" = ?": 1, @@ -247,7 +249,7 @@ "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.id, c.slug, c.locale, c.title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, + "SELECT c.id, c.slug, c.locale, c.title as title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?": 1 }, "GET /tag/webdev (cold)": { diff --git a/scripts/query-counts.queries.sqlite.json b/scripts/query-counts.queries.sqlite.json index 31d78155d6..293d6a8839 100644 --- a/scripts/query-counts.queries.sqlite.json +++ b/scripts/query-counts.queries.sqlite.json @@ -148,6 +148,7 @@ }, "GET /search (cold)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"c\".\"slug\" as \"collection_slug\" from \"_emdash_fields\" as \"f\" inner join \"_emdash_collections\" as \"c\" on \"c\".\"id\" = \"f\".\"collection_id\" where \"f\".\"slug\" = ?": 1, "select \"id\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"search_config\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"slug\" from \"_emdash_fields\" where \"collection_id\" = ? and \"searchable\" = ?": 1, @@ -155,11 +156,12 @@ "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.id, c.slug, c.locale, c.title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, + "SELECT c.id, c.slug, c.locale, c.title as title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?": 1 }, "GET /search (warm)": { "select \"a\".\"id\" as \"a_id\", \"a\".\"name\" as \"a_name\", \"a\".\"label\" as \"a_label\", \"a\".\"description\" as \"a_description\", \"w\".\"id\" as \"w_id\", \"w\".\"type\" as \"w_type\", \"w\".\"title\" as \"w_title\", \"w\".\"content\" as \"w_content\", \"w\".\"menu_name\" as \"w_menu_name\", \"w\".\"component_id\" as \"w_component_id\", \"w\".\"component_props\" as \"w_component_props\", \"w\".\"area_id\" as \"w_area_id\", \"w\".\"sort_order\" as \"w_sort_order\", \"w\".\"created_at\" as \"w_created_at\" from \"_emdash_widget_areas\" as \"a\" left join \"_emdash_widgets\" as \"w\" on \"w\".\"area_id\" = \"a\".\"id\" where \"a\".\"name\" = ? order by \"w\".\"sort_order\" asc": 1, + "select \"c\".\"slug\" as \"collection_slug\" from \"_emdash_fields\" as \"f\" inner join \"_emdash_collections\" as \"c\" on \"c\".\"id\" = \"f\".\"collection_id\" where \"f\".\"slug\" = ?": 1, "select \"id\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"search_config\" from \"_emdash_collections\" where \"slug\" = ?": 1, "select \"slug\" from \"_emdash_fields\" where \"collection_id\" = ? and \"searchable\" = ?": 1, @@ -167,7 +169,7 @@ "select * from \"_emdash_menu_items\" where \"menu_id\" = ? order by \"sort_order\" asc": 1, "select * from \"_emdash_menus\" where \"name\" = ? order by \"locale\" asc": 1, "SELECT *, (SELECT json_group_array(json_object('id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group)) FROM \"content_taxonomies\" AS ct CROSS JOIN \"taxonomies\" AS t ON t.translation_group = ct.taxonomy_id WHERE ct.collection = ? AND ct.entry_id = \"ec_pages\".id AND t.locale = \"ec_pages\".locale) AS \"_emdash_terms\", (SELECT json_group_array(json_object('roleLabel', cb.role_label, 'sortOrder', cb.sort_order, 'byline', json_object('id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group))) FROM \"_emdash_content_bylines\" AS cb CROSS JOIN \"_emdash_bylines\" AS b ON b.translation_group = cb.byline_id LEFT JOIN \"media\" AS m ON m.id = b.avatar_media_id WHERE cb.collection_slug = ? AND cb.content_id = \"ec_pages\".id AND b.locale = \"ec_pages\".locale) AS \"_emdash_bylines\" FROM \"ec_pages\" WHERE deleted_at IS NULL AND (\"status\" = 'published' OR (\"status\" = 'scheduled' AND \"scheduled_at\" <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))) ORDER BY \"created_at\" DESC, \"id\" DESC": 1, - "SELECT c.id, c.slug, c.locale, c.title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, + "SELECT c.id, c.slug, c.locale, c.title as title, snippet(\"_emdash_fts_posts\", 2, '', '', '...', 32) as snippet, bm25(\"_emdash_fts_posts\") as score FROM \"_emdash_fts_posts\" f JOIN \"ec_posts\" c ON f.id = c.id WHERE \"_emdash_fts_posts\" MATCH ? AND c.status = ? AND c.deleted_at IS NULL ORDER BY score LIMIT ?": 1, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?": 1 }, "GET /tag/webdev (cold)": { diff --git a/scripts/query-counts.snapshot.d1.json b/scripts/query-counts.snapshot.d1.json index 9de686e47a..b93ebe8f6d 100644 --- a/scripts/query-counts.snapshot.d1.json +++ b/scripts/query-counts.snapshot.d1.json @@ -15,8 +15,8 @@ "GET /posts/building-for-the-long-term (warm)": 18, "GET /rss.xml (cold)": 12, "GET /rss.xml (warm)": 2, - "GET /search (cold)": 21, - "GET /search (warm)": 10, + "GET /search (cold)": 22, + "GET /search (warm)": 11, "GET /tag/webdev (cold)": 22, "GET /tag/webdev (warm)": 10 } diff --git a/scripts/query-counts.snapshot.sqlite.json b/scripts/query-counts.snapshot.sqlite.json index 11d8677101..222f7d5ddf 100644 --- a/scripts/query-counts.snapshot.sqlite.json +++ b/scripts/query-counts.snapshot.sqlite.json @@ -15,8 +15,8 @@ "GET /posts/building-for-the-long-term (warm)": 18, "GET /rss.xml (cold)": 2, "GET /rss.xml (warm)": 2, - "GET /search (cold)": 10, - "GET /search (warm)": 10, + "GET /search (cold)": 11, + "GET /search (warm)": 11, "GET /tag/webdev (cold)": 10, "GET /tag/webdev (warm)": 10 }