feat(backend): add standalone data plane

Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
jjbrunton committed 2026-08-28 14:42:11 +01:00
1 parent 01bb8225ae
commit 006761d3b0
294 files changed
+16259 -6138

No files matched your search

+6 -3
View File
@@ -1,4 +1,7 @@
PORT=3000
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
PORT=3002
# Standalone admin API only. The token maps to ADMIN_API_SERVICE_USER_ID inside
# the API; MCP must not receive that UUID, a database URL, or a Supabase key.
SAUCI_ADMIN_API_URL=http://127.0.0.1:3003
SAUCI_ADMIN_API_TOKEN=use-a-separate-32-character-service-token
# Independent caller-facing authentication for the MCP HTTP service.
SAUCI_MCP_API_KEY=your-secret-api-key
+2 -1
View File
@@ -2,7 +2,8 @@
Hono service exposing privileged administrative tools.
- Treat the service-role key and `SAUCI_MCP_API_KEY` as secrets.
- Treat `SAUCI_ADMIN_API_TOKEN` and `SAUCI_MCP_API_KEY` as secrets. MCP must
never receive a Supabase service-role key or a direct database credential.
- Fail closed when MCP authentication is missing outside explicit local mode.
- Every mutation validates inputs, authorizes the operation, and writes audit
evidence where the product contract requires it.
+20 -10
View File
@@ -1,14 +1,24 @@
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20.10.0-slim AS build
WORKDIR /workspace
COPY package.json package-lock.json ./
COPY apps/mcp/package.json ./apps/mcp/package.json
RUN --mount=type=cache,target=/root/.npm \
npm ci --workspace sauci-mcp-server --include-workspace-root=false --ignore-scripts
COPY apps/mcp ./apps/mcp
RUN npm run typecheck --workspace sauci-mcp-server \
&& npm test --workspace sauci-mcp-server
RUN npm run build --workspace sauci-mcp-server
RUN npm prune --omit=dev --workspace sauci-mcp-server --include-workspace-root=false --ignore-scripts
FROM node:22-slim
FROM node:20.10.0-slim
ENV NODE_ENV=production PORT=3000
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json .
COPY --from=build /workspace/node_modules ./node_modules
COPY --from=build /workspace/apps/mcp/node_modules ./node_modules
COPY --from=build --chown=node:node /workspace/apps/mcp/package.json ./package.json
COPY --from=build --chown=node:node /workspace/apps/mcp/dist ./dist
USER node
EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
CMD ["node", "dist/index.mjs"]
+23
View File
@@ -0,0 +1,23 @@
# Sauci administrative MCP
The MCP service exposes 46 internal tools through eight groups: content (12),
users (7), moderation (5), redemption codes (5), administrators (5), analytics
(4), configuration (4), and feedback (4).
MCP does not connect to PostgreSQL, Supabase data APIs, or object storage. Tool
requests are translated to the standalone API's `/v1/admin/*` contract. That
API is responsible for resource allowlisting, permission checks, transactions,
and mutation audit evidence. Relationship-shaped results required by existing
tools are assembled from allowlisted API queries.
## Environment
- `SAUCI_MCP_API_KEY`: bearer credential accepted from MCP callers.
- `SAUCI_ADMIN_API_URL`: standalone API base URL. Cleartext HTTP is rejected
except for loopback development.
- `SAUCI_ADMIN_API_TOKEN`: opaque API service credential. The API maps it to
the active administrator configured by `ADMIN_API_SERVICE_USER_ID`.
The two bearer credentials serve different trust boundaries and must not be
reused. Missing admin API configuration fails when any data tool is invoked;
missing MCP caller authentication fails closed at the HTTP boundary.
+2 -3
View File
@@ -14,9 +14,7 @@
"@hono/node-server": "^1.13.7",
"@hono/mcp": "^0.1.1",
"@modelcontextprotocol/sdk": "^1.0.1",
"@supabase/supabase-js": "^2.45.0",
"dotenv": "^16.4.5",
"eventsource": "^4.1.0",
"hono": "^4.6.0",
"zod": "^3.23.0"
},
@@ -24,6 +22,7 @@
"@types/node": "^22.0.0",
"tsup": "^8.2.4",
"tsx": "^4.19.0",
"typescript": "^5.5.0"
"typescript": "^5.5.0",
"vitest": "^2.1.0"
}
}
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AdminApiClient, AdminApiError } from './admin-api.js';
afterEach(() => vi.restoreAllMocks());
function response(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
describe('AdminApiClient', () => {
it('queries the standalone API with the server bearer credential', async () => {
const fetchImpl = vi.fn(async (_input: URL | RequestInfo, _init?: RequestInit) => response({ rows: [{ id: '1' }], count: 1 }));
const client = new AdminApiClient('http://127.0.0.1:3010', 'server-secret', fetchImpl as typeof fetch);
await expect(client.query('profiles', {
filters: [{ column: 'id', op: 'eq', value: '1' }],
limit: 20,
})).resolves.toEqual({ rows: [{ id: '1' }], count: 1 });
const [url, init] = fetchImpl.mock.calls[0];
expect(String(url)).toBe('http://127.0.0.1:3010/v1/admin/query/profiles');
expect(init?.headers).toMatchObject({ authorization: 'Bearer server-secret' });
expect(JSON.parse(String(init?.body))).toEqual({
filters: [{ column: 'id', op: 'eq', value: '1' }],
limit: 20,
});
});
it('maps inserts, updates, deletes, gifts and private media URLs', async () => {
const fetchImpl = vi.fn<(_input: URL | RequestInfo, _init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(response({ rows: [{ id: 'new' }] }, 201))
.mockResolvedValueOnce(response({ row: { id: 'new', name: 'changed' } }))
.mockResolvedValueOnce(response({ deleted: true }))
.mockResolvedValueOnce(response({ expires_at: '2027-01-01T00:00:00.000Z' }))
.mockResolvedValueOnce(response({ url: 'https://media.test/signed', expires_at: '2026-08-28T00:00:00.000Z' }));
const client = new AdminApiClient('https://api.sauci.test/base/', 'token', fetchImpl as typeof fetch);
await expect(client.insert('categories', [{ name: 'New' }])).resolves.toEqual([{ id: 'new' }]);
await expect(client.update('categories', 'new', { name: 'changed' })).resolves.toMatchObject({ name: 'changed' });
await expect(client.delete('categories', 'new')).resolves.toBeUndefined();
await expect(client.giftPremium('user/id', 30, 'thanks')).resolves.toEqual({ expires_at: '2027-01-01T00:00:00.000Z' });
await expect(client.mediaUrl('media/id')).resolves.toEqual({ url: 'https://media.test/signed', expires_at: '2026-08-28T00:00:00.000Z' });
expect(fetchImpl.mock.calls.map(([url, init]) => [String(url), init?.method])).toEqual([
['https://api.sauci.test/base/v1/admin/data/categories', 'POST'],
['https://api.sauci.test/base/v1/admin/data/categories/new', 'PATCH'],
['https://api.sauci.test/base/v1/admin/data/categories/new', 'DELETE'],
['https://api.sauci.test/base/v1/admin/users/user%2Fid/gift-premium', 'POST'],
['https://api.sauci.test/base/v1/admin/media/media%2Fid/url', 'GET'],
]);
});
it('paginates queryAll until the reported count is reached', async () => {
const first = Array.from({ length: 500 }, (_, id) => ({ id }));
const fetchImpl = vi.fn<(_input: URL | RequestInfo, _init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(response({ rows: first, count: 501 }))
.mockResolvedValueOnce(response({ rows: [{ id: 500 }], count: 501 }));
const client = new AdminApiClient('http://localhost:3010', 'token', fetchImpl as typeof fetch);
await expect(client.queryAll('questions')).resolves.toHaveLength(501);
expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toMatchObject({ limit: 500, offset: 500 });
});
it('uses the aggregate feature-interest endpoint instead of exporting user rows', async () => {
const counts = [{ feature_name: 'live_draw', opt_in_count: 4, opt_in_count_last_7_days: 2 }];
const fetchImpl = vi.fn(async (_input: URL | RequestInfo, _init?: RequestInit) => response({ counts }));
const client = new AdminApiClient('https://api.sauci.test', 'token', fetchImpl as typeof fetch);
await expect(client.featureInterestCounts()).resolves.toEqual(counts);
expect(String(fetchImpl.mock.calls[0][0])).toBe('https://api.sauci.test/v1/admin/feature-interest-counts');
expect(fetchImpl.mock.calls[0][1]?.method).toBe('GET');
});
it('returns structured API failures without exposing the credential', async () => {
const fetchImpl = vi.fn(async (_input: URL | RequestInfo, _init?: RequestInit) => response({ error: { code: 'forbidden', message: 'Missing permission' } }, 403));
const client = new AdminApiClient('https://api.sauci.test', 'never-print-me', fetchImpl as typeof fetch);
const error = await client.query('profiles').catch((cause) => cause);
expect(error).toBeInstanceOf(AdminApiError);
expect(error).toMatchObject({ status: 403, code: 'forbidden', message: 'Missing permission' });
expect(String(error)).not.toContain('never-print-me');
});
it('rejects credentials sent over non-local cleartext HTTP', () => {
expect(() => new AdminApiClient('http://api.sauci.test', 'token')).toThrow(/HTTPS/);
expect(() => new AdminApiClient('http://localhost:3010', '')).toThrow(/TOKEN/);
});
});
+137
View File
@@ -0,0 +1,137 @@
export type AdminFilter = {
column: string;
op: 'eq' | 'neq' | 'in' | 'is' | 'gte' | 'lte' | 'ilike';
value: unknown;
};
export type AdminQuery = {
filters?: AdminFilter[];
order?: { column: string; ascending?: boolean };
limit?: number;
offset?: number;
};
type Fetch = typeof fetch;
export class AdminApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string,
) {
super(message);
this.name = 'AdminApiError';
}
}
function validatedBaseUrl(value: string): URL {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error('SAUCI_ADMIN_API_URL must be a valid URL');
}
const local = ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
throw new Error('SAUCI_ADMIN_API_URL must use HTTPS (HTTP is allowed only for localhost)');
}
url.pathname = url.pathname.replace(/\/$/, '');
return url;
}
export class AdminApiClient {
private readonly baseUrl: URL;
constructor(
baseUrl: string,
private readonly token: string,
private readonly fetchImpl: Fetch = fetch,
) {
this.baseUrl = validatedBaseUrl(baseUrl);
if (!token.trim()) throw new Error('SAUCI_ADMIN_API_TOKEN is required');
}
async query<T extends Record<string, unknown> = Record<string, unknown>>(resource: string, query: AdminQuery = {}) {
return this.request<{ rows: T[]; count: number }>('POST', `/v1/admin/query/${resource}`, query);
}
async queryAll<T extends Record<string, unknown> = Record<string, unknown>>(resource: string, query: Omit<AdminQuery, 'limit' | 'offset'> = {}) {
const rows: T[] = [];
for (let offset = 0; ; offset += 500) {
const page = await this.query<T>(resource, { ...query, limit: 500, offset });
rows.push(...page.rows);
if (rows.length >= page.count || page.rows.length < 500) return rows;
}
}
async insert<T extends Record<string, unknown> = Record<string, unknown>>(resource: string, records: Record<string, unknown>[]) {
const result = await this.request<{ rows: T[] }>('POST', `/v1/admin/data/${resource}`, { records });
return result.rows;
}
async update<T extends Record<string, unknown> = Record<string, unknown>>(resource: string, id: string, values: Record<string, unknown>) {
const result = await this.request<{ row: T }>('PATCH', `/v1/admin/data/${resource}/${encodeURIComponent(id)}`, { values });
return result.row;
}
async delete(resource: string, id: string): Promise<void> {
await this.request('DELETE', `/v1/admin/data/${resource}/${encodeURIComponent(id)}`);
}
async dashboard(): Promise<Record<string, number>> {
return this.request('GET', '/v1/admin/dashboard');
}
async featureInterestCounts(): Promise<Array<{ feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number }>> {
const result = await this.request<{ counts: Array<{ feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number }> }>(
'GET',
'/v1/admin/feature-interest-counts',
);
return result.counts;
}
async giftPremium(userId: string, days: number, reason?: string): Promise<{ expires_at: string }> {
return this.request('POST', `/v1/admin/users/${encodeURIComponent(userId)}/gift-premium`, { days, reason });
}
async mediaUrl(mediaId: string): Promise<{ url: string; expires_at: string }> {
return this.request('GET', `/v1/admin/media/${encodeURIComponent(mediaId)}/url`);
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await this.fetchImpl(this.endpoint(path), {
method,
headers: {
authorization: `Bearer ${this.token}`,
accept: 'application/json',
...(body === undefined ? {} : { 'content-type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const payload = await response.json().catch(() => undefined) as { error?: { code?: string; message?: string } } | undefined;
if (!response.ok) {
throw new AdminApiError(payload?.error?.message ?? `Admin API request failed with status ${response.status}`, response.status, payload?.error?.code);
}
return payload as T;
}
private endpoint(path: string): URL {
const url = new URL(this.baseUrl.origin);
const prefix = this.baseUrl.pathname === '/' ? '' : this.baseUrl.pathname;
url.pathname = `${prefix}${path}`;
return url;
}
}
let sharedClient: AdminApiClient | undefined;
export function getAdminApi(): AdminApiClient {
if (sharedClient) return sharedClient;
const url = process.env.SAUCI_ADMIN_API_URL;
const token = process.env.SAUCI_ADMIN_API_TOKEN;
if (!url || !token) {
throw new Error('SAUCI_ADMIN_API_URL and SAUCI_ADMIN_API_TOKEN are required for MCP data access');
}
sharedClient = new AdminApiClient(url, token);
return sharedClient;
}
+83
View File
@@ -0,0 +1,83 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { adminData } from './admin-data.js';
const originalUrl = process.env.SAUCI_ADMIN_API_URL;
const originalToken = process.env.SAUCI_ADMIN_API_TOKEN;
beforeAll(() => {
process.env.SAUCI_ADMIN_API_URL = 'http://localhost:3010';
process.env.SAUCI_ADMIN_API_TOKEN = 'test-service-token';
vi.stubGlobal('fetch', vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => {
const url = new URL(String(input));
const body = init?.body ? JSON.parse(String(init.body)) : {};
if (url.pathname === '/v1/admin/query/feedback') {
return Response.json({ rows: [{ id: 'f1', user_id: 'u1', question_id: 'q1' }], count: 1 });
}
if (url.pathname === '/v1/admin/query/questions') {
return Response.json({ rows: [{ id: 'q1', text: 'Question', intensity: 3, private: 'not returned in relation' }], count: 1 });
}
if (url.pathname === '/v1/admin/query/profiles') {
const rows = body.filters?.length
? [{ id: 'u1', name: 'User One', email: 'one@example.test', push_token: 'private' }]
: [
{ id: 'u1', name: 'Alex', email: 'one@example.test' },
{ id: 'u2', name: 'Sam', email: 'sam@example.test' },
{ id: 'u3', name: 'No match', email: 'none@example.test' },
];
return Response.json({ rows, count: rows.length });
}
if (url.pathname === '/v1/admin/data/categories/c1' && init?.method === 'PATCH') {
return Response.json({ row: { id: 'c1', name: body.values.name } });
}
return Response.json({ error: { code: 'unexpected', message: `${init?.method} ${url.pathname}` } }, { status: 500 });
}));
});
afterAll(() => {
vi.unstubAllGlobals();
if (originalUrl === undefined) delete process.env.SAUCI_ADMIN_API_URL;
else process.env.SAUCI_ADMIN_API_URL = originalUrl;
if (originalToken === undefined) delete process.env.SAUCI_ADMIN_API_TOKEN;
else process.env.SAUCI_ADMIN_API_TOKEN = originalToken;
});
describe('adminData compatibility behavior', () => {
it('preserves requested relationship shapes without exposing unrelated profile fields', async () => {
const result = await adminData.from('feedback').select(`
*,
user:user_id (name, email),
question:question_id (text)
`).eq('id', 'f1').single();
expect(result.error).toBeNull();
expect(result.data).toMatchObject({
id: 'f1',
user: { name: 'User One', email: 'one@example.test' },
question: { text: 'Question' },
});
expect(result.data.user).not.toHaveProperty('push_token');
expect(result.data.question).not.toHaveProperty('private');
});
it('preserves OR search, pagination and union counts client-side', async () => {
const result = await adminData.from('profiles')
.select('*', { count: 'exact' })
.or('name.ilike.%sam%,email.ilike.%sam%')
.order('created_at', { ascending: false })
.range(0, 9);
expect(result.error).toBeNull();
expect(result.count).toBe(1);
expect(result.data.map((row: Record<string, unknown>) => row.id)).toEqual(['u2']);
});
it('routes mutations through the audited standalone endpoint', async () => {
const result = await adminData.from('categories').update({ name: 'Changed' }).eq('id', 'c1').select().single();
expect(result).toEqual({ data: { id: 'c1', name: 'Changed' }, error: null, count: null });
const fetchMock = vi.mocked(fetch);
const mutation = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/v1/admin/data/categories/c1'));
expect(mutation?.[1]).toMatchObject({ method: 'PATCH' });
expect(mutation?.[1]?.headers).toMatchObject({ authorization: 'Bearer test-service-token' });
});
});
+172
View File
@@ -0,0 +1,172 @@
import { AdminApiError, getAdminApi, type AdminFilter, type AdminQuery } from './admin-api.js';
type Row = Record<string, any>;
type Result = { data: any; error: { message: string } | null; count: number | null };
type Action = 'select' | 'insert' | 'update' | 'delete';
class AdminQueryBuilder implements PromiseLike<Result> {
private action: Action = 'select';
private filters: AdminFilter[] = [];
private orderBy?: AdminQuery['order'];
private pageLimit?: number;
private pageOffset?: number;
private input?: Row | Row[];
private selection = '*';
private localPredicates: Array<(row: Row) => boolean> = [];
private cardinality: 'many' | 'single' | 'maybeSingle' = 'many';
constructor(private readonly resource: string) {}
select(columns = '*', _options?: { count?: 'exact'; head?: boolean }) { this.selection = columns; return this; }
insert(values: Row | Row[]) { this.action = 'insert'; this.input = values; return this; }
update(values: Row) { this.action = 'update'; this.input = values; return this; }
delete() { this.action = 'delete'; return this; }
eq(column: string, value: unknown) { this.filters.push({ column, op: 'eq', value }); return this; }
neq(column: string, value: unknown) { this.filters.push({ column, op: 'neq', value }); return this; }
in(column: string, value: unknown[]) { this.filters.push({ column, op: 'in', value }); return this; }
is(column: string, value: unknown) { this.filters.push({ column, op: 'is', value }); return this; }
not(column: string, operator: string, value: unknown) {
if (operator === 'is' && value === null) this.localPredicates.push((row) => row[column] !== null && row[column] !== undefined);
else throw new Error(`Unsupported admin query: not(${column}, ${operator})`);
return this;
}
or(expression: string) {
const conditions = expression.split(',').map((part) => {
const match = part.match(/^([a-z_][a-z0-9_]*)\.ilike\.(.*)$/i);
if (!match) throw new Error('Only ilike OR expressions are supported by the MCP admin client');
const pattern = match[2].replace(/^%|%$/g, '').toLocaleLowerCase();
return (row: Row) => String(row[match[1]] ?? '').toLocaleLowerCase().includes(pattern);
});
this.localPredicates.push((row) => conditions.some((condition) => condition(row)));
return this;
}
order(column: string, options?: { ascending?: boolean }) { this.orderBy = { column, ascending: options?.ascending }; return this; }
range(from: number, to: number) { this.pageOffset = from; this.pageLimit = Math.max(to - from + 1, 0); return this; }
limit(limit: number) { this.pageLimit = limit; return this; }
single() { this.cardinality = 'single'; return this.execute(); }
maybeSingle() { this.cardinality = 'maybeSingle'; return this.execute(); }
then<TResult1 = Result, TResult2 = never>(
onfulfilled?: ((value: Result) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): PromiseLike<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected);
}
private async execute(): Promise<Result> {
try {
const api = getAdminApi();
if (this.action === 'insert') {
const rows = await api.insert(this.resource, Array.isArray(this.input) ? this.input : [this.input!]);
return this.success(rows);
}
if (this.action === 'update') {
const id = this.mutationIds()[0];
const row = await api.update(this.resource, id, this.input as Row);
return this.success([row]);
}
if (this.action === 'delete') {
await Promise.all(this.mutationIds().map((id) => api.delete(this.resource, id)));
return { data: null, error: null, count: null };
}
let rows: Row[];
let count: number;
if (this.localPredicates.length) {
rows = await api.queryAll(this.resource, { filters: this.filters, order: this.orderBy });
rows = rows.filter((row) => this.localPredicates.every((predicate) => predicate(row)));
count = rows.length;
rows = rows.slice(this.pageOffset ?? 0, (this.pageOffset ?? 0) + (this.pageLimit ?? rows.length));
} else {
const result = await api.query(this.resource, {
filters: this.filters,
order: this.orderBy,
limit: this.pageLimit,
offset: this.pageOffset,
});
rows = result.rows;
count = result.count;
}
rows = await enrich(this.resource, this.selection, rows);
return this.success(rows, count);
} catch (cause) {
const message = cause instanceof Error ? cause.message : 'Unknown admin API error';
return { data: null, error: { message }, count: null };
}
}
private mutationIds(): string[] {
const idFilter = this.filters.find((filter) => filter.column === 'id' && (filter.op === 'eq' || filter.op === 'in'));
if (!idFilter) throw new Error('Admin mutations require an id filter');
return (Array.isArray(idFilter.value) ? idFilter.value : [idFilter.value]).map(String);
}
private success(rows: Row[], count: number | null = null): Result {
if (this.cardinality === 'single' && rows.length !== 1) return { data: null, error: { message: `Expected one row, received ${rows.length}` }, count };
if (this.cardinality === 'maybeSingle' && rows.length > 1) return { data: null, error: { message: `Expected at most one row, received ${rows.length}` }, count };
return { data: this.cardinality === 'many' ? rows : rows[0] ?? null, error: null, count };
}
}
async function rowsByIds(resource: string, ids: unknown[]): Promise<Map<string, Row>> {
const unique = [...new Set(ids.filter(Boolean).map(String))];
if (!unique.length) return new Map();
const rows = await getAdminApi().queryAll(resource, { filters: [{ column: 'id', op: 'in', value: unique }] });
return new Map(rows.map((row) => [String(row.id), row]));
}
async function enrich(resource: string, selection: string, rows: Row[]): Promise<Row[]> {
if (!rows.length || selection.trim() === '*') return rows;
if (resource === 'categories' && selection.includes('question_packs')) {
const packs = await getAdminApi().queryAll('question_packs');
return rows.map((row) => ({ ...row, question_packs: [{ count: packs.filter((pack) => pack.category_id === row.id).length }] }));
}
if (resource === 'question_packs' && selection.includes('questions')) {
const questions = await getAdminApi().queryAll('questions');
return rows.map((row) => ({ ...row, questions: [{ count: questions.filter((question) => question.pack_id === row.id).length }] }));
}
const relations = relationsFor(resource, selection);
let enriched = rows;
for (const relation of relations) {
const related = await rowsByIds(relation.resource, enriched.map((row) => row[relation.foreignKey]));
enriched = enriched.map((row) => {
const value = related.get(String(row[relation.foreignKey]));
const projected = value && relation.fields
? Object.fromEntries(relation.fields.map((field) => [field, value[field]]))
: value;
return { ...row, [relation.alias]: projected ?? null };
});
}
return enriched;
}
function relationsFor(resource: string, selection: string): Array<{ alias: string; resource: string; foreignKey: string; fields?: string[] }> {
const candidates: Record<string, Array<{ marker: string; alias: string; resource: string; foreignKey: string; fields?: string[] }>> = {
admin_users: [{ marker: 'user:user_id', alias: 'user', resource: 'profiles', foreignKey: 'user_id', fields: ['name', 'email', 'avatar_url'] }],
code_redemptions: [{ marker: 'user:user_id', alias: 'user', resource: 'profiles', foreignKey: 'user_id', fields: ['name', 'email'] }],
profiles: [{ marker: 'couple:couple_id', alias: 'couple', resource: 'couples', foreignKey: 'couple_id' }],
responses: [{ marker: 'question:question_id', alias: 'question', resource: 'questions', foreignKey: 'question_id', fields: ['text', 'intensity'] }],
matches: [{ marker: 'question:question_id', alias: 'question', resource: 'questions', foreignKey: 'question_id', fields: ['text'] }],
feedback: [
{ marker: 'question:question_id', alias: 'question', resource: 'questions', foreignKey: 'question_id', fields: ['text'] },
{ marker: 'user:user_id', alias: 'user', resource: 'profiles', foreignKey: 'user_id', fields: ['name', 'email'] },
],
message_reports: [
{ marker: 'message:message_id', alias: 'message', resource: 'messages', foreignKey: 'message_id' },
{ marker: 'reporter:reporter_id', alias: 'reporter', resource: 'profiles', foreignKey: 'reporter_id', fields: ['name', 'email'] },
],
};
return candidates[resource]?.filter((candidate) => selection.includes(candidate.marker)) ?? [];
}
export const adminData = {
from(resource: string) { return new AdminQueryBuilder(resource); },
async featureInterestCounts(): Promise<Result> {
try {
const counts = await getAdminApi().featureInterestCounts();
return { data: counts, error: null, count: counts.length };
} catch (cause) {
return { data: null, error: { message: cause instanceof AdminApiError ? cause.message : String(cause) }, count: null };
}
},
};
-22
View File
@@ -1,22 +0,0 @@
import { createClient } from '@supabase/supabase-js';
import { config } from 'dotenv';
config();
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
console.warn('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables');
}
export const supabase = createClient(
supabaseUrl || '',
supabaseServiceKey || '',
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
);
+8 -8
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerAdminTools(server: McpServer) {
@@ -9,7 +9,7 @@ export function registerAdminTools(server: McpServer) {
'List all admin users',
{},
async () => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('admin_users')
.select(`
*,
@@ -33,7 +33,7 @@ export function registerAdminTools(server: McpServer) {
permissions: z.array(z.string()).optional()
},
async ({ user_id, role, permissions }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('admin_users')
.insert({
user_id,
@@ -67,13 +67,13 @@ export function registerAdminTools(server: McpServer) {
permissions: z.array(z.string()).optional()
},
async ({ id, role, permissions }) => {
const { data: oldData } = await supabase.from('admin_users').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('admin_users').select('*').eq('id', id).single();
const updates: any = {};
if (role) updates.role = role;
if (permissions) updates.permissions = permissions;
const { data, error } = await supabase
const { data, error } = await adminData
.from('admin_users')
.update(updates)
.eq('id', id)
@@ -101,9 +101,9 @@ export function registerAdminTools(server: McpServer) {
'Revoke admin access',
{ id: z.string() }, // admin_users ID
async ({ id }) => {
const { data: oldData } = await supabase.from('admin_users').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('admin_users').select('*').eq('id', id).single();
const { error } = await supabase.from('admin_users').delete().eq('id', id);
const { error } = await adminData.from('admin_users').delete().eq('id', id);
if (error) throw new Error(`Failed to remove admin: ${error.message}`);
await logAudit({
@@ -129,7 +129,7 @@ export function registerAdminTools(server: McpServer) {
action: z.enum(['INSERT', 'UPDATE', 'DELETE']).optional()
},
async ({ limit, offset, table_name, action }) => {
let query = supabase
let query = adminData
.from('audit_logs')
.select('*', { count: 'exact' });
+14 -31
View File
@@ -1,5 +1,5 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
export function registerAnalyticsTools(server: McpServer) {
server.tool(
@@ -15,11 +15,11 @@ export function registerAnalyticsTools(server: McpServer) {
{ count: profiles },
{ count: couples }
] = await Promise.all([
supabase.from('categories').select('*', { count: 'exact', head: true }),
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
supabase.from('questions').select('*', { count: 'exact', head: true }),
supabase.from('profiles').select('*', { count: 'exact', head: true }),
supabase.from('couples').select('*', { count: 'exact', head: true })
adminData.from('categories').select('*', { count: 'exact', head: true }),
adminData.from('question_packs').select('*', { count: 'exact', head: true }),
adminData.from('questions').select('*', { count: 'exact', head: true }),
adminData.from('profiles').select('*', { count: 'exact', head: true }),
adminData.from('couples').select('*', { count: 'exact', head: true })
]);
return {
@@ -41,27 +41,8 @@ export function registerAnalyticsTools(server: McpServer) {
'Get feature interest stats',
{},
async () => {
const { data, error } = await supabase.rpc('get_feature_interest_counts');
// Fallback if RPC doesn't exist or fails, query table directly if possible
if (error) {
// Try raw query on feature_interests table
const { data: rawData, error: rawError } = await supabase
.from('feature_interests')
.select('feature_name');
if (rawError) throw new Error(`Failed to get feature interests: ${rawError.message}`);
// Manual aggregation
const counts: Record<string, number> = {};
rawData.forEach((r: any) => {
counts[r.feature_name] = (counts[r.feature_name] || 0) + 1;
});
return {
content: [{ type: 'text', text: JSON.stringify(counts, null, 2) }]
};
}
const { data, error } = await adminData.featureInterestCounts();
if (error) throw new Error(`Failed to get feature interests: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
@@ -75,7 +56,7 @@ export function registerAnalyticsTools(server: McpServer) {
{},
async () => {
// Aggregating onboarding data from profiles
const { data, error } = await supabase
const { data, error } = await adminData
.from('profiles')
.select('usage_reason, gender');
@@ -84,9 +65,11 @@ export function registerAnalyticsTools(server: McpServer) {
const reasons: Record<string, number> = {};
const genders: Record<string, number> = {};
data.forEach((p) => {
if (p.usage_reason) reasons[p.usage_reason] = (reasons[p.usage_reason] || 0) + 1;
if (p.gender) genders[p.gender] = (genders[p.gender] || 0) + 1;
data.forEach((p: Record<string, unknown>) => {
const usageReason = typeof p.usage_reason === 'string' ? p.usage_reason : undefined;
const gender = typeof p.gender === 'string' ? p.gender : undefined;
if (usageReason) reasons[usageReason] = (reasons[usageReason] || 0) + 1;
if (gender) genders[gender] = (genders[gender] || 0) + 1;
});
return {
+8 -8
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerCodeTools(server: McpServer) {
@@ -15,7 +15,7 @@ export function registerCodeTools(server: McpServer) {
async ({ page, limit, search }) => {
const offset = (page - 1) * limit;
let query = supabase
let query = adminData
.from('redemption_codes')
.select('*', { count: 'exact' });
@@ -48,7 +48,7 @@ export function registerCodeTools(server: McpServer) {
async ({ code, description, max_uses, expires_at, is_active }) => {
const finalCode = code || Math.random().toString(36).substring(2, 10).toUpperCase();
const { data, error } = await supabase
const { data, error } = await adminData
.from('redemption_codes')
.insert({
code: finalCode,
@@ -81,9 +81,9 @@ export function registerCodeTools(server: McpServer) {
'Enable or disable a redemption code',
{ id: z.string(), is_active: z.boolean() },
async ({ id, is_active }) => {
const { data: oldData } = await supabase.from('redemption_codes').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('redemption_codes').select('*').eq('id', id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('redemption_codes')
.update({ is_active })
.eq('id', id)
@@ -111,9 +111,9 @@ export function registerCodeTools(server: McpServer) {
'Delete a redemption code',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('redemption_codes').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('redemption_codes').select('*').eq('id', id).single();
const { error } = await supabase.from('redemption_codes').delete().eq('id', id);
const { error } = await adminData.from('redemption_codes').delete().eq('id', id);
if (error) throw new Error(`Failed to delete code: ${error.message}`);
await logAudit({
@@ -134,7 +134,7 @@ export function registerCodeTools(server: McpServer) {
'See who redeemed a code',
{ code_id: z.string() },
async ({ code_id }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('code_redemptions')
.select(`
*,
+8 -10
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerConfigTools(server: McpServer) {
@@ -9,7 +9,7 @@ export function registerConfigTools(server: McpServer) {
'Get AI configuration',
{},
async () => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('ai_config')
.select('*')
.single();
@@ -35,14 +35,14 @@ export function registerConfigTools(server: McpServer) {
},
async (updates) => {
// Get current ID (singleton table usually has 1 row, but need ID for update)
const { data: current, error: fetchError } = await supabase
const { data: current, error: fetchError } = await adminData
.from('ai_config')
.select('*')
.single();
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
const { data, error } = await supabase
const { data, error } = await adminData
.from('ai_config')
.update(updates)
.eq('id', current.id)
@@ -59,9 +59,7 @@ export function registerConfigTools(server: McpServer) {
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
}
);
@@ -70,7 +68,7 @@ export function registerConfigTools(server: McpServer) {
'Get app configuration',
{},
async () => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('app_config')
.select('*')
.single();
@@ -91,14 +89,14 @@ export function registerConfigTools(server: McpServer) {
daily_response_limit: z.number().optional()
},
async (updates) => {
const { data: current, error: fetchError } = await supabase
const { data: current, error: fetchError } = await adminData
.from('app_config')
.select('*')
.single();
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
const { data, error } = await supabase
const { data, error } = await adminData
.from('app_config')
.update(updates)
.eq('id', current.id)
+19 -19
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerContentTools(server: McpServer) {
@@ -11,7 +11,7 @@ export function registerContentTools(server: McpServer) {
'List all categories with pack counts',
{},
async () => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('categories')
.select(`
*,
@@ -38,7 +38,7 @@ export function registerContentTools(server: McpServer) {
is_public: z.boolean().default(false)
},
async ({ name, description, icon, sort_order, is_public }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('categories')
.insert({ name, description, icon, sort_order: sort_order ?? 0, is_public })
.select()
@@ -72,10 +72,10 @@ export function registerContentTools(server: McpServer) {
},
async ({ id, ...updates }) => {
// Get old values for audit
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('categories').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Category not found: ${id}`);
const { data, error } = await supabase
const { data, error } = await adminData
.from('categories')
.update(updates)
.eq('id', id)
@@ -103,10 +103,10 @@ export function registerContentTools(server: McpServer) {
'Delete a category',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('categories').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Category not found: ${id}`);
const { error } = await supabase
const { error } = await adminData
.from('categories')
.delete()
.eq('id', id);
@@ -133,7 +133,7 @@ export function registerContentTools(server: McpServer) {
'List packs for a category or all packs',
{ category_id: z.string().optional() },
async ({ category_id }) => {
let query = supabase
let query = adminData
.from('question_packs')
.select(`
*,
@@ -167,7 +167,7 @@ export function registerContentTools(server: McpServer) {
sort_order: z.number().optional()
},
async (input) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('question_packs')
.insert({ ...input, sort_order: input.sort_order ?? 0 })
.select()
@@ -202,10 +202,10 @@ export function registerContentTools(server: McpServer) {
sort_order: z.number().optional()
},
async ({ id, ...updates }) => {
const { data: oldData } = await supabase.from('question_packs').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('question_packs').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Pack not found: ${id}`);
const { data, error } = await supabase
const { data, error } = await adminData
.from('question_packs')
.update(updates)
.eq('id', id)
@@ -233,10 +233,10 @@ export function registerContentTools(server: McpServer) {
'Delete a question pack',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('question_packs').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('question_packs').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Pack not found: ${id}`);
const { error } = await supabase.from('question_packs').delete().eq('id', id);
const { error } = await adminData.from('question_packs').delete().eq('id', id);
if (error) throw new Error(`Failed to delete pack: ${error.message}`);
await logAudit({
@@ -259,7 +259,7 @@ export function registerContentTools(server: McpServer) {
'List questions for a pack',
{ pack_id: z.string() },
async ({ pack_id }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('questions')
.select('*')
.eq('pack_id', pack_id)
@@ -283,7 +283,7 @@ export function registerContentTools(server: McpServer) {
intensity: z.number().min(1).max(5).default(1)
},
async (input) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('questions')
.insert(input)
.select()
@@ -315,10 +315,10 @@ export function registerContentTools(server: McpServer) {
pack_id: z.string().optional()
},
async ({ id, ...updates }) => {
const { data: oldData } = await supabase.from('questions').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('questions').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Question not found: ${id}`);
const { data, error } = await supabase
const { data, error } = await adminData
.from('questions')
.update(updates)
.eq('id', id)
@@ -346,9 +346,9 @@ export function registerContentTools(server: McpServer) {
'Delete one or more questions',
{ ids: z.array(z.string()) },
async ({ ids }) => {
const { data: oldData } = await supabase.from('questions').select('*').in('id', ids);
const { data: oldData } = await adminData.from('questions').select('*').in('id', ids);
const { error } = await supabase.from('questions').delete().in('id', ids);
const { error } = await adminData.from('questions').delete().in('id', ids);
if (error) throw new Error(`Failed to delete questions: ${error.message}`);
// Log audit for each deleted question
+7 -7
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerFeedbackTools(server: McpServer) {
@@ -14,7 +14,7 @@ export function registerFeedbackTools(server: McpServer) {
offset: z.number().default(0)
},
async ({ type, status, limit, offset }) => {
let query = supabase
let query = adminData
.from('feedback')
.select(`
*,
@@ -41,7 +41,7 @@ export function registerFeedbackTools(server: McpServer) {
'Get full feedback details',
{ id: z.string() },
async ({ id }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('feedback')
.select(`
*,
@@ -67,9 +67,9 @@ export function registerFeedbackTools(server: McpServer) {
status: z.enum(['new', 'reviewed', 'in_progress', 'resolved', 'closed'])
},
async ({ id, status }) => {
const { data: oldData } = await supabase.from('feedback').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('feedback').select('*').eq('id', id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('feedback')
.update({ status })
.eq('id', id)
@@ -97,9 +97,9 @@ export function registerFeedbackTools(server: McpServer) {
'Add admin notes to feedback',
{ id: z.string(), notes: z.string() },
async ({ id, notes }) => {
const { data: oldData } = await supabase.from('feedback').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('feedback').select('*').eq('id', id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('feedback')
.update({ admin_notes: notes })
.eq('id', id)
+9 -9
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerModerationTools(server: McpServer) {
@@ -12,7 +12,7 @@ export function registerModerationTools(server: McpServer) {
offset: z.number().default(0)
},
async ({ limit, offset }) => {
const { data, error, count } = await supabase
const { data, error, count } = await adminData
.from('messages')
.select('*', { count: 'exact' })
.eq('moderation_status', 'flagged')
@@ -36,7 +36,7 @@ export function registerModerationTools(server: McpServer) {
offset: z.number().default(0)
},
async ({ status, limit, offset }) => {
const { data, error, count } = await supabase
const { data, error, count } = await adminData
.from('message_reports')
.select(`
*,
@@ -60,9 +60,9 @@ export function registerModerationTools(server: McpServer) {
'Mark a flagged message as safe',
{ message_id: z.string() },
async ({ message_id }) => {
const { data: oldData } = await supabase.from('messages').select('*').eq('id', message_id).single();
const { data: oldData } = await adminData.from('messages').select('*').eq('id', message_id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('messages')
.update({ moderation_status: 'safe' })
.eq('id', message_id)
@@ -90,9 +90,9 @@ export function registerModerationTools(server: McpServer) {
'Dismiss a user report',
{ report_id: z.string() },
async ({ report_id }) => {
const { data: oldData } = await supabase.from('message_reports').select('*').eq('id', report_id).single();
const { data: oldData } = await adminData.from('message_reports').select('*').eq('id', report_id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('message_reports')
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
.eq('id', report_id)
@@ -120,9 +120,9 @@ export function registerModerationTools(server: McpServer) {
'Mark a report as reviewed (action taken)',
{ report_id: z.string() },
async ({ report_id }) => {
const { data: oldData } = await supabase.from('message_reports').select('*').eq('id', report_id).single();
const { data: oldData } = await adminData.from('message_reports').select('*').eq('id', report_id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('message_reports')
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
.eq('id', report_id)
+21 -63
View File
@@ -1,7 +1,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
import { adminData } from '../lib/admin-data.js';
import { getAdminApi } from '../lib/admin-api.js';
export function registerUserTools(server: McpServer) {
server.tool(
@@ -15,7 +15,7 @@ export function registerUserTools(server: McpServer) {
async ({ page, limit, search }) => {
const offset = (page - 1) * limit;
let query = supabase
let query = adminData
.from('profiles')
.select('*', { count: 'exact' });
@@ -40,7 +40,7 @@ export function registerUserTools(server: McpServer) {
'Get full user profile details',
{ user_id: z.string() },
async ({ user_id }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('profiles')
.select(`
*,
@@ -62,7 +62,7 @@ export function registerUserTools(server: McpServer) {
'Get user question responses',
{ user_id: z.string(), limit: z.number().default(50) },
async ({ user_id, limit }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('responses')
.select(`
*,
@@ -86,13 +86,13 @@ export function registerUserTools(server: McpServer) {
{ user_id: z.string() },
async ({ user_id }) => {
// First get couple_id
const { data: profile } = await supabase.from('profiles').select('couple_id').eq('id', user_id).single();
const { data: profile } = await adminData.from('profiles').select('couple_id').eq('id', user_id).single();
if (!profile?.couple_id) {
return { content: [{ type: 'text', text: 'User is not in a couple' }] };
}
const { data, error } = await supabase
const { data, error } = await adminData
.from('matches')
.select(`
*,
@@ -114,28 +114,20 @@ export function registerUserTools(server: McpServer) {
'List user uploaded media',
{ user_id: z.string() },
async ({ user_id }) => {
// This is tricky as media is in storage, but usually referenced in messages
// We'll search messages table for media_path where user_id matches
const { data, error } = await supabase
.from('messages')
.select('id, media_path, created_at, match_id')
.eq('user_id', user_id)
.not('media_path', 'is', null)
// Files remain private; the admin API issues short-lived signed URLs.
const { data, error } = await adminData
.from('media_objects')
.select('*')
.eq('owner_id', user_id)
.is('deleted_at', null)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to get user media: ${error.message}`);
// Generate public URLs for media
const mediaWithUrls = data.map(m => {
const { data: urlData } = supabase.storage
.from('chat-media')
.getPublicUrl(m.media_path);
return {
...m,
url: urlData.publicUrl
};
});
const mediaWithUrls = await Promise.all(data.map(async (media: Record<string, any>) => ({
...media,
...(await getAdminApi().mediaUrl(media.id)),
})));
return {
content: [{ type: 'text', text: JSON.stringify(mediaWithUrls, null, 2) }]
@@ -148,7 +140,7 @@ export function registerUserTools(server: McpServer) {
'View chat messages for a match',
{ match_id: z.string(), limit: z.number().default(50) },
async ({ match_id, limit }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('messages')
.select('*')
.eq('match_id', match_id)
@@ -172,45 +164,11 @@ export function registerUserTools(server: McpServer) {
days: z.number().default(30),
reason: z.string().optional() // For audit log
},
async ({ user_id, days }) => {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + days);
const { data: oldData } = await supabase.from('profiles').select('is_premium').eq('id', user_id).single();
// We update profiles table directly or insert into subscriptions?
// Research said `profiles.is_premium` is the flag, but also mentioned `subscriptions` table.
// Usually `subscriptions` drives `is_premium` via webhooks, but for gifting we might just set the flag
// OR insert a manual subscription.
// The admin dashboard uses `auditedSupabase.insert('subscriptions', {...})`.
const { data, error } = await supabase
.from('subscriptions')
.insert({
user_id,
product_id: 'admin_gift',
status: 'active',
expires_at: expiresAt.toISOString(),
is_sandbox: false
})
.select()
.single();
if (error) throw new Error(`Failed to gift premium: ${error.message}`);
// Also ensure profile is updated (if not handled by triggers)
await supabase.from('profiles').update({ is_premium: true }).eq('id', user_id);
await logAudit({
action: 'INSERT',
table_name: 'subscriptions',
record_id: data.id,
new_values: data,
old_values: { reason: 'Premium Gift', old_is_premium: oldData?.is_premium }
});
async ({ user_id, days, reason }) => {
const { expires_at: expiresAt } = await getAdminApi().giftPremium(user_id, days, reason);
return {
content: [{ type: 'text', text: `Premium gifted to user ${user_id} until ${expiresAt.toISOString()}` }]
content: [{ type: 'text', text: `Premium gifted to user ${user_id} until ${expiresAt}` }]
};
}
);
+6 -71
View File
@@ -1,81 +1,16 @@
import { supabase } from '../lib/supabase.js';
export interface AuditLogEntry {
action: 'INSERT' | 'UPDATE' | 'DELETE';
table_name: string;
record_id?: string;
old_values?: Record<string, any>;
new_values?: Record<string, any>;
old_values?: Record<string, unknown> | null;
new_values?: Record<string, unknown> | null;
admin_user_id?: string;
}
let cachedSystemAdminId: string | null = null;
async function getSystemAdminId(): Promise<string | null> {
if (cachedSystemAdminId) return cachedSystemAdminId;
// Try to find a super_admin user
const { data, error } = await supabase
.from('admin_users')
.select('user_id')
.eq('role', 'super_admin')
.limit(1)
.maybeSingle();
if (data?.user_id) {
cachedSystemAdminId = data.user_id;
return data.user_id;
}
// Fallback: any admin user
const { data: anyAdmin } = await supabase
.from('admin_users')
.select('user_id')
.limit(1)
.maybeSingle();
if (anyAdmin?.user_id) {
cachedSystemAdminId = anyAdmin.user_id;
return anyAdmin.user_id;
}
return null;
}
/**
* Log an admin action to the audit_logs table
* Standalone admin API mutations are authorized and audited atomically by the
* API. This compatibility hook deliberately does not create a duplicate row.
*/
export async function logAudit(entry: AuditLogEntry) {
try {
let adminUserId = entry.admin_user_id;
if (!adminUserId) {
adminUserId = await getSystemAdminId() || undefined;
}
if (!adminUserId) {
console.warn('Cannot log audit entry: No admin user found to attribute action to');
return;
}
// Ensure values are plain objects, not Supabase response objects
const cleanOldValues = entry.old_values ? JSON.parse(JSON.stringify(entry.old_values)) : null;
const cleanNewValues = entry.new_values ? JSON.parse(JSON.stringify(entry.new_values)) : null;
const { error } = await supabase.from('audit_logs').insert({
action: entry.action,
table_name: entry.table_name,
record_id: entry.record_id || '00000000-0000-0000-0000-000000000000', // Fallback UUID if needed
old_values: cleanOldValues,
new_values: cleanNewValues,
admin_role: 'super_admin', // MCP acts as super admin
admin_user_id: adminUserId
});
if (error) {
console.error('Failed to write audit log:', error);
}
} catch (err) {
console.error('Error in logAudit:', err);
}
export async function logAudit(_entry: AuditLogEntry): Promise<void> {
// The called admin API mutation owns the audit transaction.
}