07fbeea09b
Ersätter den tidigare webappen på denna branch med ett fokuserat monorepo: - apps/mobile: Expo/React Native med tre vyer (Welcome, Chat, Paywall), Cognito hosted UI-inloggning (Apple/Google/e-post) och In-App Purchase/Play Billing via en gemensam purchases-modul. - services/api: en enda Lambda-backend — OpenAI Responses API med Markdown-kunskapsbas som systeminstruktioner, free tier-gräns i PostgreSQL (HTTP 402 -> paywall) och kvittoverifiering bakom ett delat PaymentProvider-interface (Apple/Google, Stripe kan läggas till för webb senare). - infra: AWS CDK-stack med API Gateway (JWT-authorizer), Lambda, Cognito, Aurora Serverless v2 och Secrets Manager. - db/migrations: minimal datamodell (users + usage), inga konversationer sparas. - GitHub Actions: CI (lint, typecheck, test) och deploy från main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
67 lines
2.8 KiB
TypeScript
67 lines
2.8 KiB
TypeScript
import { createSign } from 'node:crypto';
|
|
import { config } from '../config.js';
|
|
import { getSecret } from '../secrets.js';
|
|
import type { PaymentProvider, VerificationResult, VerifyPurchaseRequest } from './types.js';
|
|
|
|
interface ServiceAccount {
|
|
client_email: string;
|
|
private_key: string;
|
|
}
|
|
|
|
function base64url(input: string | Buffer): string {
|
|
return Buffer.from(input).toString('base64url');
|
|
}
|
|
|
|
/** Mints a Google OAuth access token from a service account key (RS256 JWT). */
|
|
async function getAccessToken(account: ServiceAccount): Promise<string> {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
const claims = base64url(
|
|
JSON.stringify({
|
|
iss: account.client_email,
|
|
scope: 'https://www.googleapis.com/auth/androidpublisher',
|
|
aud: 'https://oauth2.googleapis.com/token',
|
|
iat: now,
|
|
exp: now + 3600,
|
|
}),
|
|
);
|
|
const signer = createSign('RSA-SHA256');
|
|
signer.update(`${header}.${claims}`);
|
|
const signature = signer.sign(account.private_key).toString('base64url');
|
|
const assertion = `${header}.${claims}.${signature}`;
|
|
|
|
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
assertion,
|
|
}),
|
|
});
|
|
if (!response.ok) throw new Error(`Google token exchange failed (${response.status})`);
|
|
const data = (await response.json()) as { access_token: string };
|
|
return data.access_token;
|
|
}
|
|
|
|
export const googleProvider: PaymentProvider = {
|
|
async verify(request: VerifyPurchaseRequest): Promise<VerificationResult> {
|
|
const appSecret = await getSecret(config.appSecretArn);
|
|
const raw = appSecret.GOOGLE_SERVICE_ACCOUNT_JSON;
|
|
if (!raw) throw new Error('GOOGLE_SERVICE_ACCOUNT_JSON missing from application secret');
|
|
const account = JSON.parse(raw) as ServiceAccount;
|
|
|
|
const token = await getAccessToken(account);
|
|
const url =
|
|
`https://androidpublisher.googleapis.com/androidpublisher/v3/applications/` +
|
|
`${config.androidPackageName}/purchases/subscriptions/` +
|
|
`${encodeURIComponent(request.productId)}/tokens/${encodeURIComponent(request.receipt)}`;
|
|
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
if (response.status === 404 || response.status === 410) return { active: false };
|
|
if (!response.ok) throw new Error(`Google purchase lookup failed (${response.status})`);
|
|
|
|
const data = (await response.json()) as { expiryTimeMillis?: string };
|
|
const expiry = Number(data.expiryTimeMillis ?? 0);
|
|
return { active: expiry > Date.now(), expiresAt: expiry ? new Date(expiry) : undefined };
|
|
},
|
|
};
|