managarten/packages/shared-hono/src/credits.ts
Till JS 0077752456 fix(type-check): clear the last five failures — monorepo type-check is now 76/76 green
After the mobile-app deletion unblocked \`@context/mobile\`, five more
pre-existing failures surfaced across shared packages and two services.
All were silent-masked by the postinstall \`|| true\` for months.

- **shared-ai**: \`planner/loop.ts\` imported \`ToolSchema\` from
  \`../tools/function-schema\`, which only imports (not re-exports) the
  type. Fixed to import from the source (\`../tools/schemas\`).
- **shared-logger**: \`typeof window !== 'undefined'\` blows up under
  tsconfigs that don't include the DOM lib (e.g. uload-server's
  \`bun-types\`-only config), because shared-logger is consumed via
  source import. Replaced with a \`globalThis\`-indirected check that
  compiles under any lib configuration.
- **shared-hono**: \`credits.ts\` returned \`res.json()\` directly as
  \`Promise<T | null>\`. Modern \`@types/node\` / undici types return
  \`unknown\` strictly — cast to \`T\` at the boundary so the generic
  contract is explicit.
- **uload-server**: \`routes/analytics.ts\` + \`routes/email.ts\` still
  imported \`AuthUser\` from a \`middleware/jwt-auth\` module that was
  deleted during the migration to \`@mana/shared-hono\`. Replaced with
  \`AuthVariables\` from shared-hono, which matches the actual context
  shape set by \`authMiddleware()\`.
- **manavoxel/web**: \`guestSeed\` collection entries were wrapped in
  arrow functions, but \`local-store\` expects \`T[]\` directly and
  iterates \`seed.length\` — which on a function is 0. The "guest
  seed" was silently dead; eager-evaluating \`generateGuestWorld()\`
  once and sharing the result fixes both the type and the runtime.

Verified: \`pnpm run type-check\` from the repo root now exits 0 —
76/76 tasks successful, no failures. First fully green state since
well before the postinstall \`|| true\` was introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:53:07 +02:00

123 lines
2.8 KiB
TypeScript

/**
* Credit client for Hono backends.
*
* Drop-in replacement for @mana-core/nestjs-integration CreditClientService.
* Calls mana-credits service to validate/consume/refund credits.
*/
export interface CreditBalance {
balance: number;
totalEarned: number;
totalSpent: number;
}
export interface CreditValidationResult {
hasCredits: boolean;
availableCredits: number;
requiredCredits?: number;
}
const CREDITS_URL = () =>
process.env.MANA_CREDITS_URL || process.env.MANA_AUTH_URL || 'http://localhost:3061';
const SERVICE_KEY = () => process.env.MANA_SERVICE_KEY || '';
const APP_ID = () => process.env.APP_ID || 'unknown';
const DEFAULT_BALANCE: CreditBalance = { balance: 1000, totalEarned: 0, totalSpent: 0 };
async function callCredits<T>(path: string, options: RequestInit = {}): Promise<T | null> {
const key = SERVICE_KEY();
if (!key) {
console.warn('[credits] Service key not configured');
return null;
}
try {
const res = await fetch(`${CREDITS_URL()}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Service-Key': key,
'X-App-Id': APP_ID(),
...options.headers,
},
});
if (!res.ok) return null;
return (await res.json()) as T;
} catch (error) {
console.error('[credits] Request failed:', error);
return null;
}
}
/**
* Get user's credit balance.
*/
export async function getBalance(userId: string): Promise<CreditBalance> {
const result = await callCredits<CreditBalance>(`/api/v1/internal/credits/balance/${userId}`);
return result || DEFAULT_BALANCE;
}
/**
* Validate that user has enough credits for an operation.
*/
export async function validateCredits(
userId: string,
_operation: string,
amount: number
): Promise<CreditValidationResult> {
try {
const balance = await getBalance(userId);
return {
hasCredits: balance.balance >= amount,
availableCredits: balance.balance,
requiredCredits: amount,
};
} catch {
return { hasCredits: true, availableCredits: 0, requiredCredits: amount };
}
}
/**
* Consume credits after a successful operation.
*/
export async function consumeCredits(
userId: string,
operation: string,
amount: number,
description: string,
metadata?: Record<string, unknown>
): Promise<boolean> {
const result = await callCredits('/api/v1/internal/credits/use', {
method: 'POST',
body: JSON.stringify({
userId,
amount,
appId: APP_ID(),
description,
metadata: { operation, ...metadata },
}),
});
return !!result;
}
/**
* Refund credits after a failed operation.
*/
export async function refundCredits(
userId: string,
amount: number,
description: string,
metadata?: Record<string, unknown>
): Promise<boolean> {
const result = await callCredits('/api/v1/internal/credits/refund', {
method: 'POST',
body: JSON.stringify({
userId,
amount,
appId: APP_ID(),
description,
metadata,
}),
});
return !!result;
}