Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/site/app/_components/profile-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export function ProfileForm() {
defaultValues={values}
errors={errors}
idPrefix="account-"
importOpenProfile
/>
<button className="button button--primary" type="submit" disabled={busy}>
{busy ? "Saving…" : "Save details"}
Expand Down
2 changes: 1 addition & 1 deletion apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@nightcell7/game-core": "workspace:*",
"@nightcell7/ui": "workspace:*",
"@profullstack/footer": "latest",
"@profullstack/profile": "0.1.6",
"@profullstack/profile": "0.1.7",
"better-auth": "1.7.13",
"next": "16.4.3",
"react": "^19.3.1",
Expand Down
9 changes: 6 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@nightcell7/database": "workspace:*",
"@simplewebauthn/server": "13.3.3",
"better-auth": "^1.7.13",
"@profullstack/profile": "0.1.7",
"zod": "^4.6.7"
}
}
38 changes: 37 additions & 1 deletion packages/auth/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { passkey } from "@better-auth/passkey";
import type { Database } from "@nightcell7/database";
import { accounts, passkeys, sessions, users, verifications } from "@nightcell7/database";
import { saveOpenProfileContact, type ProfileStore } from "@profullstack/profile";
import { DISPLAY_NAME_MAX, DISPLAY_NAME_MIN } from "./index.js";

/**
Expand Down Expand Up @@ -36,6 +37,8 @@ export interface AuthConfig {
* not registered and the site hides its buttons.
*/
coinpay?: CoinpaySignInConfig | null;
/** Shared private contact profile; used to import CoinPay's OpenProfile claim. */
profiles?: ProfileStore;
/** Tests only: a Better Auth adapter in place of the Drizzle one over `db`. */
adapter?: BetterAuthOptions["database"];
}
Expand Down Expand Up @@ -101,6 +104,25 @@ export function coinpayProvider(config: CoinpaySignInConfig) {
};
}

/**
* CoinPay's OpenProfile URL is also present in its verified OIDC ID token.
* Better Auth verifies that token against the discovered JWKS before its
* account database hooks run, so the claim can be used for this import.
*/
function openProfileUrlFromVerifiedToken(idToken: unknown): string | null {
if (typeof idToken !== "string") return null;
try {
const [, encoded] = idToken.split(".");
if (!encoded) return null;
const claims = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as {
openprofile_url?: unknown;
};
return typeof claims.openprofile_url === "string" ? claims.openprofile_url : null;
} catch {
return null;
}
}

export type AuthEmail =
| { kind: "verify-email"; to: string; verifyUrl: string }
| { kind: "password-reset"; to: string; resetUrl: string }
Expand Down Expand Up @@ -179,7 +201,21 @@ export function createAuth(config: AuthConfig) {
account: {
// Never persist social-login provider tokens (see stripOAuthTokens).
create: {
before: async (account) => ({ data: stripOAuthTokens(account) }),
before: async (account) => {
if (account.providerId === COINPAY_PROVIDER_ID && config.profiles) {
const openProfileUrl = openProfileUrlFromVerifiedToken(account.idToken);
if (openProfileUrl) {
await saveOpenProfileContact(config.profiles, account.userId, openProfileUrl).catch(
(error) =>
console.warn(
"[auth] CoinPay OpenProfile import failed:",
error instanceof Error ? error.message : error,
),
);
}
}
return { data: stripOAuthTokens(account) };
},
},
update: {
before: async (account) => ({ data: stripOAuthTokens(account) }),
Expand Down
2 changes: 1 addition & 1 deletion services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@nightcell7/multiplayer-protocol": "workspace:*",
"@nightcell7/observability": "workspace:*",
"@profullstack/affiliates": "0.3.0",
"@profullstack/profile": "0.1.6",
"@profullstack/profile": "0.1.7",
"@profullstack/redeem-credits": "0.2.0",
"bullmq": "^6.4.2",
"disposable-email-domains": "1.0.62",
Expand Down
4 changes: 3 additions & 1 deletion services/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const health = new HealthReporter("api", env.BUILD_VERSION, PROTOCOL_VERSION);

const redis = new Redis(env.REDIS_URL, { maxRetriesPerRequest: null });
const database = getDatabase({ url: env.DATABASE_URL });
const profiles = createProfileStore(getPool(), { keyType: "text" });

const queues = new Map<string, Queue>();
function queueFor(name: string): Queue {
Expand Down Expand Up @@ -81,6 +82,7 @@ const auth = createAuth({
clientSecret: env.COINPAY_OAUTH_CLIENT_SECRET,
}
: null,
profiles,
// Verification and reset mail goes through the same queue as everything
// else, so a mail outage delays sign-ups rather than failing them outright.
sendEmail: async (job) => {
Expand Down Expand Up @@ -117,7 +119,7 @@ const app = createApp({
health,
repos: createRepositories(database),
// user_profiles, created by the drizzle migration 0002_profile_fields.
profiles: createProfileStore(getPool(), { keyType: "text" }),
profiles,
credits,
rateLimiter: new RedisRateLimiter(redis),
coinpay,
Expand Down
Loading