@tuturuuu/utils 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/package.json +5 -4
- package/src/__tests__/api-proxy-guard.test.ts +419 -12
- package/src/__tests__/app-url.test.ts +20 -0
- package/src/__tests__/upstash-rest.test.ts +3 -0
- package/src/abuse-protection/__tests__/edge-trust.test.ts +200 -0
- package/src/abuse-protection/__tests__/index.test.ts +64 -8
- package/src/abuse-protection/constants.ts +4 -0
- package/src/abuse-protection/edge-trust.ts +344 -0
- package/src/abuse-protection/index.ts +85 -98
- package/src/abuse-protection/reputation.ts +10 -0
- package/src/api-proxy-guard.ts +347 -24
- package/src/internal-domains.ts +54 -2
- package/src/money.test.ts +82 -0
- package/src/money.ts +120 -0
- package/src/next-config.test.ts +13 -0
- package/src/next-config.ts +0 -8
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/upstash-rest.ts +3 -1
- package/src/workspace-helper.ts +28 -1
package/src/money.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized money/minor-unit helpers.
|
|
3
|
+
*
|
|
4
|
+
* Money is modeled as integer **minor units** (the currency's smallest unit:
|
|
5
|
+
* cents for USD/EUR, but whole units for zero-decimal currencies like JPY/VND,
|
|
6
|
+
* and thousandths for three-decimal currencies like BHD). This matches how
|
|
7
|
+
* payment processors (e.g. Polar/Stripe) represent amounts and avoids floating
|
|
8
|
+
* point drift on money.
|
|
9
|
+
*
|
|
10
|
+
* Use {@link majorToMinor} when persisting a user-entered amount, and
|
|
11
|
+
* {@link minorToMajor} / {@link formatMoneyFromMinor} when reading a stored
|
|
12
|
+
* amount back for display. The conversion factor is currency-aware, so never
|
|
13
|
+
* hard-code `* 100` / `/ 100`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { getCurrencyLocale } from './currencies';
|
|
17
|
+
import { formatCurrency } from './format';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_FRACTION_DIGITS = 2;
|
|
20
|
+
|
|
21
|
+
// Intl construction is comparatively expensive; cache per currency code.
|
|
22
|
+
const fractionDigitsCache = new Map<string, number>();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Round a scaled value to the nearest integer while neutralizing binary
|
|
26
|
+
* floating-point error (e.g. `1.005 * 100` is `100.4999…`, which must round to
|
|
27
|
+
* `101`, not `100`). Trimming to 4 extra decimals before rounding removes the
|
|
28
|
+
* noise without affecting legitimate values.
|
|
29
|
+
*/
|
|
30
|
+
function roundScaled(value: number): number {
|
|
31
|
+
return Math.round(Number(value.toFixed(4)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Number of decimal places a currency uses (USD → 2, JPY/VND → 0, BHD → 3).
|
|
36
|
+
* Falls back to 2 for unknown/invalid currency codes.
|
|
37
|
+
*/
|
|
38
|
+
export function getCurrencyFractionDigits(currency = 'USD'): number {
|
|
39
|
+
const code = currency.toUpperCase();
|
|
40
|
+
const cached = fractionDigitsCache.get(code);
|
|
41
|
+
if (cached !== undefined) return cached;
|
|
42
|
+
|
|
43
|
+
let digits = DEFAULT_FRACTION_DIGITS;
|
|
44
|
+
try {
|
|
45
|
+
digits =
|
|
46
|
+
new Intl.NumberFormat(getCurrencyLocale(code), {
|
|
47
|
+
style: 'currency',
|
|
48
|
+
currency: code,
|
|
49
|
+
}).resolvedOptions().maximumFractionDigits ?? DEFAULT_FRACTION_DIGITS;
|
|
50
|
+
} catch {
|
|
51
|
+
digits = DEFAULT_FRACTION_DIGITS;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fractionDigitsCache.set(code, digits);
|
|
55
|
+
return digits;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Multiplier between a currency's major and minor units
|
|
60
|
+
* (USD → 100, JPY/VND → 1, BHD → 1000).
|
|
61
|
+
*/
|
|
62
|
+
export function getMinorUnitFactor(currency = 'USD'): number {
|
|
63
|
+
return 10 ** getCurrencyFractionDigits(currency);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Convert a major-unit amount (e.g. dollars `100`, or `9.99`) into integer
|
|
68
|
+
* minor units (e.g. `10000`, `999`) for storage / sending to a payment API.
|
|
69
|
+
*/
|
|
70
|
+
export function majorToMinor(major: number, currency = 'USD'): number {
|
|
71
|
+
if (!Number.isFinite(major)) return 0;
|
|
72
|
+
return roundScaled(major * getMinorUnitFactor(currency));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Convert integer minor units (e.g. `10000`) back into a major-unit amount
|
|
77
|
+
* (e.g. `100`) for display or for ledgers that store major units.
|
|
78
|
+
*/
|
|
79
|
+
export function minorToMajor(minor: number, currency = 'USD'): number {
|
|
80
|
+
if (!Number.isFinite(minor)) return 0;
|
|
81
|
+
return minor / getMinorUnitFactor(currency);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Round a major-unit amount to the currency's precision (drops sub-minor-unit
|
|
86
|
+
* noise from user input before it is converted/stored).
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeAmount(major: number, currency = 'USD'): number {
|
|
89
|
+
if (!Number.isFinite(major)) return 0;
|
|
90
|
+
const factor = getMinorUnitFactor(currency);
|
|
91
|
+
return roundScaled(major * factor) / factor;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The numeric step a major-unit price input should use for the currency
|
|
96
|
+
* ('1' for zero-decimal currencies, '0.01' for two-decimal, etc.).
|
|
97
|
+
*/
|
|
98
|
+
export function getAmountStep(currency = 'USD'): string {
|
|
99
|
+
const fractionDigits = getCurrencyFractionDigits(currency);
|
|
100
|
+
if (fractionDigits <= 0) return '1';
|
|
101
|
+
return (1 / 10 ** fractionDigits).toFixed(fractionDigits);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Format an amount stored in minor units as a localized currency string.
|
|
106
|
+
* This is the canonical display path for minor-unit-stored money.
|
|
107
|
+
*/
|
|
108
|
+
export function formatMoneyFromMinor(
|
|
109
|
+
minorAmount: number,
|
|
110
|
+
currency = 'USD',
|
|
111
|
+
locale?: string,
|
|
112
|
+
options?: Partial<Intl.NumberFormatOptions>
|
|
113
|
+
): string {
|
|
114
|
+
return formatCurrency(
|
|
115
|
+
minorToMajor(minorAmount, currency),
|
|
116
|
+
currency,
|
|
117
|
+
locale,
|
|
118
|
+
options
|
|
119
|
+
);
|
|
120
|
+
}
|
package/src/next-config.test.ts
CHANGED
|
@@ -23,6 +23,19 @@ describe('createTuturuuuNextConfig', () => {
|
|
|
23
23
|
expect(config.typescript?.ignoreBuildErrors).toBe(true);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
it('does not expose loopback hosts to the shared image optimizer allowlist', () => {
|
|
27
|
+
const config = createTuturuuuNextConfig();
|
|
28
|
+
|
|
29
|
+
expect(config.images?.remotePatterns).not.toContainEqual({
|
|
30
|
+
protocol: 'http',
|
|
31
|
+
hostname: 'localhost',
|
|
32
|
+
});
|
|
33
|
+
expect(config.images?.remotePatterns).not.toContainEqual({
|
|
34
|
+
protocol: 'http',
|
|
35
|
+
hostname: '127.0.0.1',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
26
39
|
it('allows the exact worktree-prefixed Portless host for Next dev assets', () => {
|
|
27
40
|
const originalPortlessUrl = process.env.PORTLESS_URL;
|
|
28
41
|
|
package/src/next-config.ts
CHANGED
|
@@ -19,14 +19,6 @@ type NextImageRemotePattern = NonNullable<
|
|
|
19
19
|
>[number];
|
|
20
20
|
|
|
21
21
|
export const TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS = [
|
|
22
|
-
{
|
|
23
|
-
protocol: 'http',
|
|
24
|
-
hostname: 'localhost',
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
protocol: 'http',
|
|
28
|
-
hostname: '127.0.0.1',
|
|
29
|
-
},
|
|
30
22
|
{
|
|
31
23
|
protocol: 'https',
|
|
32
24
|
hostname: '**.supabase.co',
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
|
|
8
8
|
describe('platform release metadata', () => {
|
|
9
9
|
it('uses the centralized shared browser app version', () => {
|
|
10
|
-
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.
|
|
10
|
+
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.12.0'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('normalizes generated metadata and derives a short hash', () => {
|
package/src/platform-release.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
|
|
2
2
|
|
|
3
|
-
export const TUTURUUU_PLATFORM_VERSION = '0.
|
|
3
|
+
export const TUTURUUU_PLATFORM_VERSION = '0.12.0'; // x-release-please-version
|
|
4
4
|
|
|
5
5
|
export type PlatformBuildMetadataInput = {
|
|
6
6
|
builtAt?: string | null;
|
package/src/upstash-rest.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Redis } from '@upstash/redis';
|
|
|
2
2
|
|
|
3
3
|
export type UpstashRestRedisClient = Pick<
|
|
4
4
|
Redis,
|
|
5
|
-
'decr' | 'del' | 'expire' | 'get' | 'incr' | 'set' | 'ttl'
|
|
5
|
+
'decr' | 'del' | 'expire' | 'get' | 'incr' | 'mget' | 'scan' | 'set' | 'ttl'
|
|
6
6
|
>;
|
|
7
7
|
export type UpstashRatelimitRedisClient = Pick<
|
|
8
8
|
Redis,
|
|
@@ -30,6 +30,8 @@ export async function getUpstashRestRedisClient(): Promise<UpstashRestRedisClien
|
|
|
30
30
|
expire: (key, seconds) => client.expire(key, seconds),
|
|
31
31
|
get: <T = unknown>(key: string) => client.get<T>(key),
|
|
32
32
|
incr: (key) => client.incr(key),
|
|
33
|
+
mget: (...args) => client.mget(...args),
|
|
34
|
+
scan: client.scan.bind(client),
|
|
33
35
|
set: (key, value, options) => client.set(key, value, options),
|
|
34
36
|
ttl: (key) => client.ttl(key),
|
|
35
37
|
};
|
package/src/workspace-helper.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
} from '@tuturuuu/types';
|
|
13
13
|
import type { WorkspaceSecret } from '@tuturuuu/types/primitives/WorkspaceSecret';
|
|
14
14
|
import type { NextRequest } from 'next/server';
|
|
15
|
+
import { cache } from 'react';
|
|
15
16
|
import {
|
|
16
17
|
PERSONAL_WORKSPACE_SLUG,
|
|
17
18
|
ROOT_WORKSPACE_ID,
|
|
@@ -703,7 +704,33 @@ async function resolveWorkspaceIdForPermissions({
|
|
|
703
704
|
return workspace.id;
|
|
704
705
|
}
|
|
705
706
|
|
|
706
|
-
|
|
707
|
+
/**
|
|
708
|
+
* Resolve the caller's workspace permissions.
|
|
709
|
+
*
|
|
710
|
+
* The common Server Component flow (`getPermissions({ wsId })`, no explicit
|
|
711
|
+
* `user`/`request`) is memoized per request via React `cache()` keyed on the
|
|
712
|
+
* primitive `wsId`, so multiple callers in the same render tree (e.g. a layout
|
|
713
|
+
* and its page) share a single permission lookup instead of each running ~3
|
|
714
|
+
* queries. Flows that pass an explicit `user` or `request` bypass the cache to
|
|
715
|
+
* avoid any cross-request contamination.
|
|
716
|
+
*/
|
|
717
|
+
export async function getPermissions(args: {
|
|
718
|
+
user?: AuthenticatedWorkspacePrincipal | null;
|
|
719
|
+
wsId: string;
|
|
720
|
+
request?: Request;
|
|
721
|
+
}): Promise<PermissionsResult | null> {
|
|
722
|
+
if (!args.user && !args.request) {
|
|
723
|
+
return getPermissionsByWsId(args.wsId);
|
|
724
|
+
}
|
|
725
|
+
return getPermissionsImpl(args);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const getPermissionsByWsId = cache(
|
|
729
|
+
async (wsId: string): Promise<PermissionsResult | null> =>
|
|
730
|
+
getPermissionsImpl({ wsId })
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
async function getPermissionsImpl({
|
|
707
734
|
user,
|
|
708
735
|
wsId,
|
|
709
736
|
request,
|