mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
01bb8225ae
commit
006761d3b0
294 files changed
+16259
-6138
No files matched your search
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(`
|
||||
*,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
Reference in new issue
Block a user