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
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ when the failure is field-level. `correlation_id` is on every error;
| `FORBIDDEN` | 403 | Reserved name, blocked address |
| `NOT_FOUND` | 404 | No such tag, address, or route |
| `METHOD_NOT_ALLOWED` | 405 | Wrong verb on a known path |
| `CONFLICT` | 409 | Username or address already registered |
| `CONFLICT` | 409 | Username already taken, or an address is at its 5-username limit |
| `PAYLOAD_TOO_LARGE` | 413 | Body over the 10kb cap |
| `UNSUPPORTED_MEDIA_TYPE` | 415 | Non-JSON body on a JSON endpoint |
| `VALIDATION_FAILED` | 422 | Body failed its schema |
Expand All @@ -252,7 +252,7 @@ turns an error into a response:
```js
const { ApiError } = require('./src/errors');

return next(new ApiError('CONFLICT', 'Address already registered'));
return next(new ApiError('CONFLICT', 'Username is already taken. Please choose another.'));
```

A `5xx` from an unexpected throw always reports the generic message so
Expand Down Expand Up @@ -310,19 +310,23 @@ Resolves a given username tag to a Stellar address.
- `500 Internal Server Error`: Database lookup failed.

### `POST /register`
Registers a new username and associates it with a Stellar address.
Registers a new username and associates it with a Stellar address. An address
may hold up to 5 usernames (aliases), e.g. `payments*domain` and
`support*domain` for one business account. The first username registered for an
address is its primary; reverse (`type=id`) federation lookups resolve to it.
- **Body Parameters (JSON):**
- `username` (string) - The desired username.
- `address` (string) - The user's Stellar address.
- **Returns:** A JSON object with registration details `{ ok: true, username, address }`.
- **Returns:** A JSON object with registration details `{ ok: true, username, address, is_primary }`.
- **Status Codes:**
- `200 OK`: Registration successful.
- `400 Bad Request`: Missing `username` or `address`.
- `409 Conflict`: Address or username already registered.
- `409 Conflict`: Username already taken, or the address already has the maximum of 5 usernames.
- `500 Internal Server Error`: Database lookup or insertion failed.

### `GET /lookup`
Resolves a given Stellar address to its registered username.
Resolves a given Stellar address to its registered username. When an address has
several usernames, the primary one is returned.
- **Query Parameter:** `address` (string) - The Stellar address to lookup.
- **Returns:** A JSON object with `username` and `address`.
- **Status Codes:**
Expand Down Expand Up @@ -443,7 +447,7 @@ The repository includes a dedicated CLI tool (`scripts/deploy.js` and `./scripts
./scripts/deploy_contract.sh deploy --network mainnet --source S... --admin G...

# Upgrade an existing contract to newly compiled WASM
./scripts/deploy_contract.sh upgrade --contract-id CDNQ7... --network testnet --source S...
./scripts/deploy_contract.sh upgrade --contract-id C... --network testnet --source S...

# Compile and optimize WASM only
./scripts/deploy_contract.sh build
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- #613 — allow several federation usernames (aliases) per Stellar address.

-- The address can no longer be unique now that multiple usernames may point
-- at it. A plain index keeps reverse (type=id) lookups fast.
DROP INDEX "username_registry_address_key";
CREATE INDEX "username_registry_address_idx" ON "username_registry"("address");

-- `is_primary` marks the username that reverse federation lookups resolve to.
ALTER TABLE "username_registry" ADD COLUMN "is_primary" BOOLEAN NOT NULL DEFAULT false;

-- Every address currently has exactly one username; it becomes the primary.
UPDATE "username_registry" SET "is_primary" = true WHERE "deleted_at" IS NULL;
12 changes: 11 additions & 1 deletion stellar-payment-platform/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@ generator client {
// Federation registry mapping a human-readable username (e.g. "lekan*localhost")
// to its Stellar address. Maps to the legacy "username_registry" table so the
// data shape is preserved across the SQLite -> PostgreSQL migration.
//
// #613 — an address may carry several usernames (aliases), e.g.
// "payments*domain" and "support*domain" for one business account. `address`
// is therefore no longer unique; `isPrimary` marks the one username that
// reverse (type=id) federation lookups resolve to. The first username
// registered for an address is its primary.
model User {
username String @id
address String @unique
address String
isPrimary Boolean @default(false) @map("is_primary")
memoType String? @map("memo_type")
memo String?
createdAt DateTime @default(now()) @map("created_at")
Expand All @@ -28,6 +35,9 @@ model User {
webhooks Webhook[] // <-- add this line

@@index([username])
// Reverse federation lookups (type=id) and /lookup?address= filter by
// address, which is no longer backed by a unique index.
@@index([address])
// Serves the keyset (cursor) pagination seeks used by the /users and
// /lookup list endpoints: (created_at DESC, username DESC) walks this
// index backwards, so page depth no longer affects query cost.
Expand Down
37 changes: 31 additions & 6 deletions stellar-payment-platform/register-endpoint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jest.mock("./prismaClient", () => ({
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
count: jest.fn(),
create: jest.fn(),
},
$queryRaw: jest.fn().mockResolvedValue([{ '1': 1 }]),
Expand Down Expand Up @@ -68,11 +69,12 @@ describe("POST /register - integration test coverage", () => {
({ prisma } = require("./prismaClient"));

prisma.user.findFirst.mockReset();
prisma.user.count.mockReset();
prisma.user.create.mockReset();
});

test("registers successfully with valid payload", async () => {
prisma.user.findFirst.mockResolvedValue(null);
prisma.user.count.mockResolvedValue(0);
prisma.user.create.mockResolvedValue({
username: "alice*localhost",
address: VALID_ADDRESS,
Expand All @@ -87,33 +89,56 @@ describe("POST /register - integration test coverage", () => {
ok: true,
username: "alice*localhost",
address: VALID_ADDRESS,
is_primary: true,
});
expect(prisma.user.findFirst).toHaveBeenCalledWith({
expect(prisma.user.count).toHaveBeenCalledWith({
where: { address: VALID_ADDRESS, deletedAt: null },
});
expect(prisma.user.create).toHaveBeenCalledWith({
data: {
username: "alice*localhost",
address: VALID_ADDRESS,
isPrimary: true,
},
});
});

test("returns 409 when address already exists", async () => {
prisma.user.findFirst.mockResolvedValue({
username: "existing*localhost",
test("registers an alias (non-primary) when the address already has a username", async () => {
prisma.user.count.mockResolvedValue(1);
prisma.user.create.mockResolvedValue({
username: "bob*localhost",
address: VALID_ADDRESS,
});

const response = await request(app)
.post("/register")
.send({ username: "bob", address: VALID_ADDRESS });

expect(response.status).toBe(201);
expect(response.body).toMatchObject({ ok: true, is_primary: false });
expect(prisma.user.create).toHaveBeenCalledWith({
data: {
username: "bob*localhost",
address: VALID_ADDRESS,
isPrimary: false,
},
});
});

test("returns 409 once the address has the maximum of 5 usernames", async () => {
prisma.user.count.mockResolvedValue(5);

const response = await request(app)
.post("/register")
.send({ username: "sixth", address: VALID_ADDRESS });

expect(response.status).toBe(409);
expect(response.body).toMatchObject({
success: false,
error: { code: 'CONFLICT', message: 'Address already registered' },
error: { code: 'CONFLICT' },
});
expect(response.body.error.message).toMatch(/maximum of 5/);
expect(prisma.user.create).not.toHaveBeenCalled();
});

test("returns 422 when required payload fields are missing", async () => {
Expand Down
25 changes: 21 additions & 4 deletions stellar-payment-platform/register-multisigner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,10 @@ describe('POST /register - Multi-Signer Threshold Verification', () => {
});

describe('Account Lookup and Conflict Detection', () => {
it('should reject duplicate address registration', async () => {
it('should reject registration once the address has 5 usernames', async () => {
const accountId = 'GDZST3XVCDTUJ76ZAV2HA72KYQM3DGLLFVDNNZ6XTQCR3BQFGMQ25E4Z';
prisma.user.findFirst.mockResolvedValue({ username: 'existing' });

prisma.user.count.mockResolvedValue(5);

const response = await request(app)
.post('/register')
Expand All @@ -295,7 +295,24 @@ describe('POST /register - Multi-Signer Threshold Verification', () => {
});

expect(response.status).toBe(409);
expect(response.body.error.message).toContain('Address already registered');
expect(response.body.error.message).toMatch(/maximum of 5/);
});

it('should register an additional username as an alias for an existing address', async () => {
const accountId = 'GDZST3XVCDTUJ76ZAV2HA72KYQM3DGLLFVDNNZ6XTQCR3BQFGMQ25E4Z';

prisma.user.count.mockResolvedValue(2);

const response = await request(app)
.post('/register')
.send({
username: 'newuser',
address: accountId,
signature: accountId,
});

expect(response.status).toBe(201);
expect(response.body).toMatchObject({ ok: true, is_primary: false });
});

it('should handle account not found error', async () => {
Expand Down
52 changes: 33 additions & 19 deletions stellar-payment-platform/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ const {
normalizeNameTag,
validateMemo,
RESERVED_NAMES,
MAX_USERNAMES_PER_ADDRESS,
PRIMARY_USERNAME_ORDER,
USER_DATABASE,
shouldFallbackToLocalRegistry,
} = require('./src/utils');
Expand Down Expand Up @@ -349,14 +351,9 @@ const listLocalUsers = async (search, page, limit, cursorPoint = null) => {
);
};

const registerLocalUser = async ({ username, address }) => {
const existingByAddress = await getLocalUserByAddress(address);
if (existingByAddress) {
const conflictError = new Error('Address already registered');
conflictError.statusCode = 409;
throw conflictError;
}

const registerLocalUser = async ({ username, address, isPrimary = false }) => {
// #613 — several usernames may share an address, so an existing address is
// no longer a conflict; only a duplicate username is.
const existingByUsername = await getLocalUserByUsername(username);
if (existingByUsername) {
const conflictError = new Error('Username is already taken. Please choose another.');
Expand All @@ -365,9 +362,9 @@ const registerLocalUser = async ({ username, address }) => {
}

await poolRun(
`INSERT INTO username_registry (username, address, created_at)
VALUES (?, ?, ?)`,
[username, address, new Date().toISOString()],
`INSERT INTO username_registry (username, address, is_primary, created_at)
VALUES (?, ?, ?, ?)`,
[username, address, isPrimary, new Date().toISOString()],
);
};

Expand All @@ -390,9 +387,12 @@ app.get('/federation', etagCache, validateSchema({ query: federationQuerySchema
if (type === 'id') {
const cacheKey = federationIdKey(queryValue);
const cached = await federationLookupCached(cacheKey, async () => {
// #613 — an address can have several usernames; a reverse lookup
// resolves to the primary one.
const row = await prisma.user.findFirst({
where: { address: { equals: queryValue, mode: 'insensitive' }, deletedAt: null },
select: { username: true, address: true, memoType: true, memo: true, flaggedAt: true },
orderBy: PRIMARY_USERNAME_ORDER,
});

if (!row) return null;
Expand Down Expand Up @@ -613,24 +613,34 @@ app.post('/register', idempotencyMiddleware(redisClient), requireJson, validateS
}

try {
let existing = null;
// #613 — an address may carry several usernames (aliases). Registration
// adds another while the address is under the cap; the first username
// registered for an address becomes its primary. Reverse (type=id)
// federation lookups resolve to that primary.
let usernameCount = 0;
try {
existing = await prisma.user.findFirst({
usernameCount = await prisma.user.count({
where: { address, deletedAt: null },
});
} catch (error) {
if (!shouldFallbackToLocalRegistry(error)) {
throw error;
}

existing = await getLocalUserByAddress(address);
// Degraded path: the exact alias count is unavailable, so fall back to
// a presence check. The 5-username cap is enforced best-effort here.
usernameCount = (await getLocalUserByAddress(address)) ? 1 : 0;
}

if (existing) {
const conflictError = new Error('Address already registered');
conflictError.statusCode = 409;
return next(conflictError);
if (usernameCount >= MAX_USERNAMES_PER_ADDRESS) {
return next(
new ApiError(
'CONFLICT',
`This address already has the maximum of ${MAX_USERNAMES_PER_ADDRESS} federation usernames.`,
),
);
}
const isPrimary = usernameCount === 0;

let verificationResult = null;
if (signature) {
Expand Down Expand Up @@ -688,6 +698,7 @@ app.post('/register', idempotencyMiddleware(redisClient), requireJson, validateS
data: {
username: normalizedUsername,
address,
isPrimary,
...(memoType && { memoType, memo }),
},
});
Expand All @@ -698,13 +709,14 @@ app.post('/register', idempotencyMiddleware(redisClient), requireJson, validateS
throw error;
}

await registerLocalUser({ username: normalizedUsername, address });
await registerLocalUser({ username: normalizedUsername, address, isPrimary });
}

return res.status(201).json({
ok: true,
username: normalizedUsername,
address,
is_primary: isPrimary,
federation_address: `${normalizedUsername}*${process.env.DOMAIN || 'localhost'}`,
...(verificationResult && {
verification: {
Expand Down Expand Up @@ -752,9 +764,11 @@ app.get('/lookup', validateSchema({ query: lookupQuerySchema }), async (req, res
const result = await lookupCached(address, async () => {
let row;
try {
// #613 — an address can have several usernames; return the primary.
row = await prisma.user.findFirst({
where: { address, deletedAt: null },
select: { username: true },
orderBy: PRIMARY_USERNAME_ORDER,
});
} catch (error) {
if (!shouldFallbackToLocalRegistry(error)) {
Expand Down
2 changes: 1 addition & 1 deletion stellar-payment-platform/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,7 @@ describe('Database disconnection — 503 handling', () => {
['P1001'],
['P1008'],
])('POST /api/v1/register returns 503 when Prisma throws %s', async (code) => {
prisma.user.findFirst.mockRejectedValue(makePrismaError(code));
prisma.user.count.mockRejectedValue(makePrismaError(code));

const res = await request(app)
.post('/api/v1/register')
Expand Down
5 changes: 3 additions & 2 deletions stellar-payment-platform/sql-injection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,9 @@ describe('#35 Injection safety — POST /register (address conflict check)', ()
// Either created (201) or rejected as a conflict (409) — never a crash.
expect([201, 409]).toContain(res.status);

expect(prisma.user.findFirst).toHaveBeenCalledTimes(1);
const arg = prisma.user.findFirst.mock.calls[0][0];
// The address feeds the alias-count check as a bound Prisma argument.
expect(prisma.user.count).toHaveBeenCalledTimes(1);
const arg = prisma.user.count.mock.calls[0][0];
expect(arg.where.address).toBe(payload);
},
);
Expand Down
4 changes: 4 additions & 0 deletions stellar-payment-platform/src/routes/v1/federationRoutes.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const express = require('express');
const { prisma } = require('../../../prismaClient');
const { normalizeNameTag, etagCache, USER_DATABASE } = require('../../db');
const { PRIMARY_USERNAME_ORDER } = require('../../utils');
const {
federationNameKey,
federationIdKey,
Expand All @@ -21,9 +22,12 @@ module.exports = (redisClient) => {
if (type === 'id') {
const cacheKey = federationIdKey(queryValue);
const cached = await federationLookupCached(cacheKey, async () => {
// #613 — an address can have several usernames; a reverse lookup
// resolves to the primary one.
const row = await prisma.user.findFirst({
where: { address: { equals: queryValue, mode: 'insensitive' }, deletedAt: null },
select: { username: true, address: true, memoType: true, memo: true },
orderBy: PRIMARY_USERNAME_ORDER,
});

if (!row) return null;
Expand Down
Loading
Loading