Skip to content

Commit d6a202d

Browse files
Merge pull request #76 from morelucks/feature/api-keys-vaults-tables
feat: api_keys and vaults tables
2 parents f35b173 + 9ba6b37 commit d6a202d

6 files changed

Lines changed: 176 additions & 0 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,32 @@ The API will be available at http://localhost:3000, and the PostgreSQL database
8686
| `npm start` | Run compiled `dist/index.js` |
8787
| `npm test` | Run unit/integration tests |
8888

89+
## Database migrations
90+
91+
This repository includes SQL migrations for `api_keys` and `vaults` in `migrations/`.
92+
93+
- `api_keys` stores only `key_hash` (never the raw API key).
94+
- `api_keys` enforces unique `(user_id, api_id)` and has an index on `(user_id, prefix)` for key lookup.
95+
- `vaults` stores per-user per-network snapshots with unique `(user_id, network)`.
96+
97+
Run migrations with PostgreSQL:
98+
99+
```bash
100+
psql "$DATABASE_URL" -f migrations/0001_create_api_keys_and_vaults.up.sql
101+
```
102+
103+
Rollback:
104+
105+
```bash
106+
psql "$DATABASE_URL" -f migrations/0001_create_api_keys_and_vaults.down.sql
107+
```
108+
109+
Validate issue #9 requirements locally:
110+
111+
```bash
112+
npm run validate:issue-9
113+
```
114+
89115
## Project layout
90116

91117
```text
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
DROP TABLE IF EXISTS vaults;
2+
DROP TABLE IF EXISTS api_keys;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
CREATE TABLE api_keys (
2+
id BIGSERIAL PRIMARY KEY,
3+
user_id BIGINT NOT NULL,
4+
api_id BIGINT NOT NULL,
5+
key_hash TEXT NOT NULL,
6+
prefix VARCHAR(16) NOT NULL,
7+
scopes TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
8+
rate_limit_per_minute INTEGER,
9+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10+
last_used_at TIMESTAMPTZ,
11+
CONSTRAINT api_keys_user_api_unique UNIQUE (user_id, api_id),
12+
CONSTRAINT api_keys_rate_limit_positive CHECK (
13+
rate_limit_per_minute IS NULL OR rate_limit_per_minute > 0
14+
)
15+
);
16+
17+
CREATE INDEX idx_api_keys_user_prefix ON api_keys (user_id, prefix);
18+
19+
CREATE TABLE vaults (
20+
id BIGSERIAL PRIMARY KEY,
21+
user_id BIGINT NOT NULL,
22+
stellar_vault_contract_id TEXT NOT NULL,
23+
network VARCHAR(32) NOT NULL,
24+
balance_snapshot NUMERIC(20, 7) NOT NULL DEFAULT 0,
25+
last_synced_at TIMESTAMPTZ,
26+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
27+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
28+
CONSTRAINT vaults_user_network_unique UNIQUE (user_id, network),
29+
CONSTRAINT vaults_balance_snapshot_non_negative CHECK (balance_snapshot >= 0)
30+
);
31+
32+
CREATE INDEX idx_vaults_user_network ON vaults (user_id, network);

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
"db:studio": "drizzle-kit studio"
1212
"lint": "eslint . --ext .ts",
1313
"typecheck": "tsc --noEmit",
14+
"test": "jest --runInBand",
15+
"validate:issue-9": "node scripts/validate-issue-9.mjs"
1416
"test": "node --import tsx --test \"src/**/*.test.ts\""
1517
},
1618
"dependencies": {

scripts/validate-issue-9.mjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
const cwd = process.cwd();
5+
const upPath = path.join(cwd, 'migrations', '0001_create_api_keys_and_vaults.up.sql');
6+
const downPath = path.join(cwd, 'migrations', '0001_create_api_keys_and_vaults.down.sql');
7+
8+
function assertMatch(sql, regex, message) {
9+
if (!regex.test(sql)) {
10+
throw new Error(message);
11+
}
12+
}
13+
14+
function assertNoMatch(sql, regex, message) {
15+
if (regex.test(sql)) {
16+
throw new Error(message);
17+
}
18+
}
19+
20+
const up = fs.readFileSync(upPath, 'utf8');
21+
const down = fs.readFileSync(downPath, 'utf8');
22+
23+
assertMatch(up, /create table api_keys/i, 'api_keys table is missing');
24+
assertMatch(up, /\buser_id\b/i, 'api_keys.user_id is missing');
25+
assertMatch(up, /\bapi_id\b/i, 'api_keys.api_id is missing');
26+
assertMatch(up, /\bkey_hash\b/i, 'api_keys.key_hash is missing');
27+
assertMatch(up, /\bprefix\b/i, 'api_keys.prefix is missing');
28+
assertMatch(up, /\bscopes\b/i, 'api_keys.scopes is missing');
29+
assertMatch(up, /\brate_limit_per_minute\b/i, 'api_keys.rate_limit_per_minute is missing');
30+
assertMatch(up, /\bcreated_at\b/i, 'api_keys.created_at is missing');
31+
assertMatch(up, /\blast_used_at\b/i, 'api_keys.last_used_at is missing');
32+
assertMatch(up, /unique\s*\(\s*user_id\s*,\s*api_id\s*\)/i, 'api_keys unique(user_id, api_id) is missing');
33+
assertMatch(
34+
up,
35+
/create index idx_api_keys_user_prefix on api_keys\s*\(\s*user_id\s*,\s*prefix\s*\)/i,
36+
'api_keys index(user_id, prefix) is missing'
37+
);
38+
assertNoMatch(up, /\bapi_key\b/i, 'raw api_key column detected');
39+
assertNoMatch(up, /\braw_key\b/i, 'raw_key column detected');
40+
41+
assertMatch(up, /create table vaults/i, 'vaults table is missing');
42+
assertMatch(up, /\buser_id\b/i, 'vaults.user_id is missing');
43+
assertMatch(up, /\bstellar_vault_contract_id\b/i, 'vaults.stellar_vault_contract_id is missing');
44+
assertMatch(up, /\bnetwork\b/i, 'vaults.network is missing');
45+
assertMatch(up, /\bbalance_snapshot\b/i, 'vaults.balance_snapshot is missing');
46+
assertMatch(up, /\blast_synced_at\b/i, 'vaults.last_synced_at is missing');
47+
assertMatch(up, /\bcreated_at\b/i, 'vaults.created_at is missing');
48+
assertMatch(up, /\bupdated_at\b/i, 'vaults.updated_at is missing');
49+
assertMatch(up, /unique\s*\(\s*user_id\s*,\s*network\s*\)/i, 'vaults unique(user_id, network) is missing');
50+
51+
assertMatch(down, /drop table if exists vaults/i, 'down migration must drop vaults');
52+
assertMatch(down, /drop table if exists api_keys/i, 'down migration must drop api_keys');
53+
54+
console.log('Issue #9 migration validation passed.');

src/migrations.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
const migrationDir = path.join(process.cwd(), 'migrations');
5+
const upMigrationPath = path.join(
6+
migrationDir,
7+
'0001_create_api_keys_and_vaults.up.sql'
8+
);
9+
const downMigrationPath = path.join(
10+
migrationDir,
11+
'0001_create_api_keys_and_vaults.down.sql'
12+
);
13+
14+
function read(filePath: string): string {
15+
return fs.readFileSync(filePath, 'utf8');
16+
}
17+
18+
describe('Issue #9 migrations', () => {
19+
it('creates api_keys table with required columns and constraints', () => {
20+
const sql = read(upMigrationPath);
21+
22+
expect(sql).toMatch(/create table api_keys/i);
23+
expect(sql).toMatch(/\buser_id\b/i);
24+
expect(sql).toMatch(/\bapi_id\b/i);
25+
expect(sql).toMatch(/\bkey_hash\b/i);
26+
expect(sql).toMatch(/\bprefix\b/i);
27+
expect(sql).toMatch(/\bscopes\b/i);
28+
expect(sql).toMatch(/\brate_limit_per_minute\b/i);
29+
expect(sql).toMatch(/\bcreated_at\b/i);
30+
expect(sql).toMatch(/\blast_used_at\b/i);
31+
expect(sql).toMatch(/unique\s*\(\s*user_id\s*,\s*api_id\s*\)/i);
32+
expect(sql).toMatch(
33+
/create index idx_api_keys_user_prefix on api_keys\s*\(\s*user_id\s*,\s*prefix\s*\)/i
34+
);
35+
36+
expect(sql).not.toMatch(/\bapi_key\b/i);
37+
expect(sql).not.toMatch(/\braw_key\b/i);
38+
});
39+
40+
it('creates vaults table with required columns and constraints', () => {
41+
const sql = read(upMigrationPath);
42+
43+
expect(sql).toMatch(/create table vaults/i);
44+
expect(sql).toMatch(/\buser_id\b/i);
45+
expect(sql).toMatch(/\bstellar_vault_contract_id\b/i);
46+
expect(sql).toMatch(/\bnetwork\b/i);
47+
expect(sql).toMatch(/\bbalance_snapshot\b/i);
48+
expect(sql).toMatch(/\blast_synced_at\b/i);
49+
expect(sql).toMatch(/\bcreated_at\b/i);
50+
expect(sql).toMatch(/\bupdated_at\b/i);
51+
expect(sql).toMatch(/unique\s*\(\s*user_id\s*,\s*network\s*\)/i);
52+
});
53+
54+
it('includes rollback migration for both tables', () => {
55+
const sql = read(downMigrationPath);
56+
57+
expect(sql).toMatch(/drop table if exists vaults/i);
58+
expect(sql).toMatch(/drop table if exists api_keys/i);
59+
});
60+
});

0 commit comments

Comments
 (0)