Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cef7aa7
wip
gianfra-t Jul 31, 2026
ac91342
fix(api): harden credential and limit enforcement
gianfra-t Aug 3, 2026
acbcc23
Merge remote-tracking branch 'origin/staging' into add-limits-and-key…
gianfra-t Aug 3, 2026
46eb2b1
docs(api): correct ramp-info authentication contract
gianfra-t Aug 3, 2026
0c4f57f
Merge remote-tracking branch 'origin/staging' into cleanup-legacy-schema
gianfra-t Aug 3, 2026
ef7dad5
Merge branch 'add-limits-and-key-to-dashboard' into cleanup-legacy-sc…
gianfra-t Aug 3, 2026
4fcb35f
fix(api): run legacy-schema drop after the credential migrations
gianfra-t Aug 3, 2026
e3c82fe
fix(api): address low-severity credential review findings
gianfra-t Aug 3, 2026
fdd2dca
fix(api): remove legacy provider schema dependencies
gianfra-t Aug 3, 2026
d5b3fd6
fix(dashboard): format amounts and hide initial ramps
gianfra-t Aug 3, 2026
d072a41
add exploratory query for the tax_ids migration table.
gianfra-t Aug 3, 2026
716a5c3
feat(api): add Avenia legacy status audit
gianfra-t Aug 4, 2026
955649f
Merge remote-tracking branch 'origin/staging' into cleanup-legacy-schema
gianfra-t Aug 4, 2026
b580053
fix(api): align credential tests with legacy schema cleanup
gianfra-t Aug 4, 2026
0800962
fix(api): scope Avenia ownership checks across all profile entities
gianfra-t Aug 4, 2026
9dd600b
docs(repo): reconcile identity model and cleanup report with shipped …
gianfra-t Aug 4, 2026
c08f22d
test(api): mock profile entity lookup in limits test
gianfra-t Aug 4, 2026
5ab571e
fix(repo): prepare legacy cleanup and preserve amount precision
gianfra-t Aug 4, 2026
1237886
fix(api): reconcile renamed migration names in SequelizeMeta
ebma Aug 5, 2026
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
2 changes: 1 addition & 1 deletion .agents/skills/vortex-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ const info = await vortex.getRampInfo();
// { corridors: { BR: { kycStatus, canBuy, canSell }, ... } }
```

`GET /v1/ramp-info` accepts public, secret, or session capability, derives the profile from that credential/session, and returns no exact limits, PII, provider IDs, failure reasons, account details, or ramp history.
`GET /v1/ramp-info` accepts public or secret API credential capability, derives the profile from that credential, and returns no exact limits, PII, provider IDs, failure reasons, account details, or ramp history. Supabase sessions do not authorize this endpoint.

## Common failures
- `401 Unauthorized` — `X-API-Key` missing, malformed, or wrong environment.
Expand Down
9 changes: 7 additions & 2 deletions apps/api/scripts/api-credential-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ function assertManifestEntry(value: unknown, index: number): asserts value is Ap
if (typeof entry.expiresAt !== "string" || Number.isNaN(new Date(entry.expiresAt).getTime())) {
throw new Error(`Manifest entry ${index} has an invalid expiresAt`);
}
// A past expiry would migrate a credential that is dead on arrival, silently cutting off
// the partner the migration is meant to preserve.
if (new Date(entry.expiresAt).getTime() <= Date.now()) {
throw new Error(`Manifest entry ${index} has an expiresAt in the past`);
}
}

export async function loadApiCredentialMigrationManifest(path: string): Promise<ApiCredentialMigrationEntry[]> {
Expand Down Expand Up @@ -90,10 +95,10 @@ async function validateManifest(
const partnerIds = [...new Set(manifest.map(entry => entry.partnerId).filter((id): id is string => id !== null))];
const [profiles, partners] = await Promise.all([
User.findAll({ attributes: ["id"], transaction, where: { id: { [Op.in]: profileIds } } }),
Partner.findAll({ attributes: ["id"], transaction, where: { id: { [Op.in]: partnerIds } } })
Partner.findAll({ attributes: ["id"], transaction, where: { id: { [Op.in]: partnerIds }, isActive: true } })
]);
if (profiles.length !== profileIds.length) throw new Error("A manifest profileId does not exist");
if (partners.length !== partnerIds.length) throw new Error("A manifest partnerId does not exist");
if (partners.length !== partnerIds.length) throw new Error("A manifest partnerId does not exist or is not active");

const validated = manifest.map(entry => {
const publicKey = activeById.get(entry.publicKeyId);
Expand Down
144 changes: 144 additions & 0 deletions apps/api/scripts/audit-avenia-entity-scope.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
-- Avenia entity-scope audit: sizes the population affected by single-entity ownership
-- checks before deciding between profile-wide checks (code fix) and data re-homing.
-- Read-only. Mirrors the type-less getOrCreateCustomerEntityForProfile resolution exactly:
-- the profile's active entity when set (and owned), otherwise its oldest entity
-- (created_at ASC, id ASC). A provider row on any other entity is invisible to the
-- single-entity checks: its owner is denied (403 / "No completed Avenia profile found").
-- * Check 1 is the decision input: 0 means no CURRENT row is affected.
-- * Check 2 must be 0 regardless (the resolver throws on it).
-- * INFO checks size the populations where mismatches live or can re-appear.
-- Execute with psql.

\set ON_ERROR_STOP on
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;

-- Section 0: sanity — zeros here on a populated database mean RLS is filtering the role
-- and every other result below is meaningless.
SELECT '0. sanity: provider_customers visible' AS check, count(*) AS rows FROM provider_customers
UNION ALL
SELECT '0. sanity: customer_entities visible', count(*) FROM customer_entities;

-- ---------------------------------------------------------------------------
-- Section 1: the decision checks.
-- ---------------------------------------------------------------------------

-- 1. Avenia rows the single-entity ownership check would deny their rightful owner.
SELECT '1. avenia rows invisible to single-entity checks (0 = no current victim)' AS check, count(*) AS rows
FROM provider_customers pc
JOIN customer_entities ce ON ce.id = pc.customer_entity_id
JOIN profiles p ON p.id = ce.profile_id
CROSS JOIN LATERAL (
SELECT COALESCE(
(SELECT a.id FROM customer_entities a
WHERE a.id = p.active_customer_entity_id AND a.profile_id = p.id),
(SELECT o.id FROM customer_entities o
WHERE o.profile_id = p.id
ORDER BY o.created_at ASC, o.id ASC
LIMIT 1)
) AS entity_id
) resolved
WHERE pc.provider = 'avenia'
AND pc.customer_entity_id <> resolved.entity_id

UNION ALL

-- 2. Corrupt active-entity pointers (resolver throws ACTIVE_ENTITY_OWNERSHIP_MISMATCH).
SELECT '2. profiles whose active entity is not theirs (expect 0)', count(*)
FROM profiles p
WHERE p.active_customer_entity_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM customer_entities ce
WHERE ce.id = p.active_customer_entity_id AND ce.profile_id = p.id
)

UNION ALL

-- ---------------------------------------------------------------------------
-- Section 2: INFO — populations where mismatches live or can re-appear.
-- ---------------------------------------------------------------------------

-- 2a. Multi-entity profiles: the only population where a mismatch is possible. Any of
-- these can later flip its resolved entity via active-entity selection (immutable once
-- set), turning a today-visible row into a check-1 victim without any data change.
SELECT '2a. INFO profiles owning more than one entity', count(*)
FROM (
SELECT ce.profile_id FROM customer_entities ce GROUP BY ce.profile_id HAVING count(*) > 1
) multi

UNION ALL

-- 2b. Migration-040 fold signature: avenia rows whose owning entity type differs from the
-- row's customer_type (business rows folded onto the individual entity).
SELECT '2b. INFO avenia rows typed differently than their owning entity', count(*)
FROM provider_customers pc
JOIN customer_entities ce ON ce.id = pc.customer_entity_id
WHERE pc.provider = 'avenia'
AND pc.customer_type <> ce.type

UNION ALL

-- 2c. Same as check 1 for the other providers (their services are entity-scoped too).
SELECT '2c. INFO non-avenia rows invisible to single-entity checks', count(*)
FROM provider_customers pc
JOIN customer_entities ce ON ce.id = pc.customer_entity_id
JOIN profiles p ON p.id = ce.profile_id
CROSS JOIN LATERAL (
SELECT COALESCE(
(SELECT a.id FROM customer_entities a
WHERE a.id = p.active_customer_entity_id AND a.profile_id = p.id),
(SELECT o.id FROM customer_entities o
WHERE o.profile_id = p.id
ORDER BY o.created_at ASC, o.id ASC
LIMIT 1)
) AS entity_id
) resolved
WHERE pc.provider <> 'avenia'
AND pc.customer_entity_id <> resolved.entity_id

UNION ALL

-- 2d. Profiles owning several APPROVED avenia rows across entities with no active-entity
-- selection to disambiguate: profile-wide resolution rejects these as ambiguous.
SELECT '2d. INFO profiles with >1 approved avenia row and no active-entity tiebreak', count(*)
FROM (
SELECT ce.profile_id
FROM provider_customers pc
JOIN customer_entities ce ON ce.id = pc.customer_entity_id
JOIN profiles p ON p.id = ce.profile_id
WHERE pc.provider = 'avenia' AND pc.status = 'approved'
GROUP BY ce.profile_id, p.active_customer_entity_id
HAVING count(*) > 1
AND count(*) FILTER (WHERE pc.customer_entity_id = p.active_customer_entity_id) <> 1
) ambiguous;

-- ---------------------------------------------------------------------------
-- Section 3: detail — every check-1 row by non-PII identifier.
-- ---------------------------------------------------------------------------
SELECT
pc.id AS provider_customer_id,
pc.status,
pc.customer_type,
pc.provider_subaccount_id,
ce.id AS owning_entity_id,
ce.type AS owning_entity_type,
resolved.entity_id AS resolved_entity_id,
p.id AS profile_id,
p.active_customer_entity_id IS NOT NULL AS has_active_selection
FROM provider_customers pc
JOIN customer_entities ce ON ce.id = pc.customer_entity_id
JOIN profiles p ON p.id = ce.profile_id
CROSS JOIN LATERAL (
SELECT COALESCE(
(SELECT a.id FROM customer_entities a
WHERE a.id = p.active_customer_entity_id AND a.profile_id = p.id),
(SELECT o.id FROM customer_entities o
WHERE o.profile_id = p.id
ORDER BY o.created_at ASC, o.id ASC
LIMIT 1)
) AS entity_id
) resolved
WHERE pc.provider = 'avenia'
AND pc.customer_entity_id <> resolved.entity_id
ORDER BY pc.created_at;

COMMIT;
39 changes: 39 additions & 0 deletions apps/api/scripts/export-unmigrated-avenia-customers.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- Export the result grid as CSV from the controlled database environment, then use it as
-- input to reconcile-unmigrated-avenia-status.ts. This query does not expose raw CPF/CNPJ.
-- The result is still restricted data because hashes, user IDs, and provider identifiers
-- can be linked back to customers.

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;

WITH legacy_avenia AS (
SELECT
encode(
sha256(convert_to(regexp_replace(t.tax_id, '[^0-9]', '', 'g'), 'UTF8')),
'hex'
) AS legacy_tax_hash,
t.user_id,
t.account_type::text AS account_type,
t.sub_account_id,
t.kyc_attempt,
t.internal_status::text AS legacy_status
FROM tax_ids t
)
SELECT
t.legacy_tax_hash,
CASE WHEN t.user_id IS NULL THEN 'OWNERLESS' ELSE 'USER_OWNED' END AS owner_status,
COALESCE(t.user_id::text, '') AS user_id,
t.account_type,
t.sub_account_id,
COALESCE(t.kyc_attempt, '') AS kyc_attempt,
COALESCE(t.legacy_status, '') AS legacy_status
FROM legacy_avenia t
WHERE COALESCE(t.sub_account_id, '') <> ''
AND NOT EXISTS (
SELECT 1
FROM provider_customers pc
WHERE pc.provider = 'avenia'
AND pc.tax_reference_hash = t.legacy_tax_hash
)
ORDER BY t.user_id NULLS FIRST, t.legacy_tax_hash;

ROLLBACK;
Loading
Loading