@sendinel/mcp-server 1.0.213 → 1.0.215
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/build/lib/approval-actions.js +5 -0
- package/build/lib/survey-link-resolver.js +1 -0
- package/build/server.js +3 -3
- package/build/tools/connector.js +4 -0
- package/build/tools/contacts.js +4 -0
- package/build/tools/content.js +42 -0
- package/build/tools/data.js +86 -36
- package/build/tools/forms.js +107 -16
- package/build/tools/gdpr.js +81 -22
- package/build/tools/org.js +58 -36
- package/build/tools/short-links.js +39 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
|
|
|
18
18
|
[event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
|
|
19
19
|
inputs, idempotency, subscription requirements, and conditional step examples.
|
|
20
20
|
|
|
21
|
-
Sendinel currently exposes 35 tool groups and
|
|
21
|
+
Sendinel currently exposes 35 tool groups and 356 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
|
|
22
22
|
|
|
23
23
|
| Group | Count | Purpose |
|
|
24
24
|
|-------|-------|---------|
|
|
@@ -35,19 +35,19 @@ Sendinel currently exposes 35 tool groups and 352 tools (gateway mode exposes 4
|
|
|
35
35
|
| `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
|
|
36
36
|
| `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
|
|
37
37
|
| `contacts` | 40 | Subscriber CRUD, saved views, tags, email log, suppression, merge, timeline |
|
|
38
|
-
| `content` |
|
|
38
|
+
| `content` | 15 | Content blocks, gated asset links, blog ingest and auto-publish, sources |
|
|
39
39
|
| `data` | 19 | Platform connectors, imports, API keys, migration jobs, notifications, tenant export |
|
|
40
40
|
| `data-proposals` | 3 | Data-derived campaign proposal generation, listing, approval |
|
|
41
41
|
| `delivery-ops` | 25 | Domains, DNSBL, deliverability diagnostics, queue, DLQ, exports, blackout dates, abuse overrides |
|
|
42
42
|
| `drafts` | 6 | Draft create/approve/reject and draft-to-campaign promotion |
|
|
43
|
-
| `forms` |
|
|
43
|
+
| `forms` | 5 | Form creation, listing, stats |
|
|
44
44
|
| `gdpr` | 4 | Contact data deletion and GDPR log |
|
|
45
45
|
| `landing-pages` | 5 | Landing page CRUD |
|
|
46
46
|
| `lists` | 7 | Static contact lists and membership management |
|
|
47
47
|
| `newsletters` | 12 | Newsletter publications, issues, subscribers, scheduling, sending, stats |
|
|
48
48
|
| `org` | 24 | Team members, plan usage/limits, audit log, model registry, white-label, support tickets, outbound digest, provider switching |
|
|
49
49
|
| `segments` | 6 | Audience builder: create, edit, natural-language generation, preview |
|
|
50
|
-
| `short-links` |
|
|
50
|
+
| `short-links` | 4 | Short links, click stats, QR codes |
|
|
51
51
|
| `signatures` | 7 | Sending signature blocks |
|
|
52
52
|
| `sites` | 7 | Site, sender, and brand voice management |
|
|
53
53
|
| `skills` | 3 | Operator skills catalog |
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export const GATED_ACTIONS = new Set([
|
|
2
|
+
// #4392: edits and reversible archive/disable transitions affect public visitors.
|
|
3
|
+
'update_form',
|
|
4
|
+
'archive_form',
|
|
5
|
+
'update_short_link',
|
|
6
|
+
'update_asset_link',
|
|
2
7
|
'set_link_domain', // domain routing and optional customer DNS writes, one approval
|
|
3
8
|
'recheck_domain_dns', // live provider verification may request an EE certificate
|
|
4
9
|
'draft_agent_reply',
|
|
@@ -14,6 +14,7 @@ export async function resolveSurveyShortLink(sb, projectId, surveyId) {
|
|
|
14
14
|
.from('short_links')
|
|
15
15
|
.select('id, slug, project_id')
|
|
16
16
|
.eq('id', survey.short_link_id)
|
|
17
|
+
.is('disabled_at', null)
|
|
17
18
|
.eq('project_id', projectId)
|
|
18
19
|
.maybeSingle();
|
|
19
20
|
return link?.slug ? `${appBaseUrl()}/s/${link.slug}` : null;
|
package/build/server.js
CHANGED
|
@@ -120,7 +120,7 @@ const WRITE_TOOLS = new Set([
|
|
|
120
120
|
'trigger_automation', 'enroll_automation', 'create_escalation_ladder', 'enroll_in_escalation_ladder',
|
|
121
121
|
'update_scoring_rules', 'register_domain', 'publish_dmarc_record',
|
|
122
122
|
'update_warmup_schedule', 'pause_warmup', 'resume_warmup',
|
|
123
|
-
'create_form', 'invite_team_member',
|
|
123
|
+
'create_form', 'update_form', 'archive_form', 'invite_team_member',
|
|
124
124
|
'remove_team_member', 'set_team_member_role', 'set_member_project_access',
|
|
125
125
|
'set_active_project',
|
|
126
126
|
'resend_invitation', 'revoke_invitation',
|
|
@@ -129,7 +129,7 @@ const WRITE_TOOLS = new Set([
|
|
|
129
129
|
'create_api_key', 'revoke_api_key', 'mark_notifications_read',
|
|
130
130
|
'migrate_campaigns_from', 'start_platform_migration',
|
|
131
131
|
'create_content_block', 'delete_content_block', 'trigger_content_pipeline',
|
|
132
|
-
'create_asset_link',
|
|
132
|
+
'create_asset_link', 'update_asset_link',
|
|
133
133
|
'set_white_label_settings', 'add_white_label_domain',
|
|
134
134
|
'create_blog_post',
|
|
135
135
|
'generate_blog_post_image',
|
|
@@ -141,7 +141,7 @@ const WRITE_TOOLS = new Set([
|
|
|
141
141
|
'create_custom_webhook', 'rotate_custom_webhook_secret',
|
|
142
142
|
'update_brand_voice', 'promote_draft_to_campaign',
|
|
143
143
|
'propose_campaign_from_data', 'approve_data_proposal',
|
|
144
|
-
'create_survey', 'update_survey', 'create_short_link', 'generate_qr_code',
|
|
144
|
+
'create_survey', 'update_survey', 'create_short_link', 'update_short_link', 'generate_qr_code',
|
|
145
145
|
'create_signature', 'update_signature',
|
|
146
146
|
'mark_ai_work_item_seen', 'snooze_ai_work_item', 'dismiss_ai_work_item',
|
|
147
147
|
'submit_ai_context_event',
|
package/build/tools/connector.js
CHANGED
|
@@ -178,6 +178,7 @@ export function registerConnectorTools(server) {
|
|
|
178
178
|
return j({ error: tableError instanceof Error ? dbToolError(tableError).error : 'Could not list database tables' });
|
|
179
179
|
}
|
|
180
180
|
});
|
|
181
|
+
// No approval preflight (#4550): this tool has no target row to reject.
|
|
181
182
|
server.registerTool('install_connector_schema', {
|
|
182
183
|
title: 'Install Connector Schema',
|
|
183
184
|
annotations: {
|
|
@@ -195,6 +196,7 @@ export function registerConnectorTools(server) {
|
|
|
195
196
|
const result = await callConnectorApi('/api/dashboard/connector/schema/install', { method: 'POST' });
|
|
196
197
|
return j(result.ok ? result.data : { error: result.error });
|
|
197
198
|
});
|
|
199
|
+
// No approval preflight (#4550): this tool has no target row to reject.
|
|
198
200
|
server.registerTool('install_connector_view', {
|
|
199
201
|
title: 'Install Connector Live View',
|
|
200
202
|
annotations: {
|
|
@@ -223,6 +225,7 @@ export function registerConnectorTools(server) {
|
|
|
223
225
|
});
|
|
224
226
|
return j(result.ok ? result.data : { error: result.error });
|
|
225
227
|
});
|
|
228
|
+
// No approval preflight (#4550): mapping is validated by the dashboard route only after this gate.
|
|
226
229
|
server.registerTool('set_connector_mapping', {
|
|
227
230
|
title: 'Set Connector Mapping',
|
|
228
231
|
annotations: {
|
|
@@ -271,6 +274,7 @@ export function registerConnectorTools(server) {
|
|
|
271
274
|
});
|
|
272
275
|
return j(result.ok ? result.data : { error: result.error });
|
|
273
276
|
});
|
|
277
|
+
// No approval preflight (#4550): this tool has no target row to reject.
|
|
274
278
|
server.registerTool('resync_connector', {
|
|
275
279
|
title: 'Resync Connector Contacts',
|
|
276
280
|
annotations: {
|
package/build/tools/contacts.js
CHANGED
|
@@ -2437,6 +2437,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2437
2437
|
return dbErrorResult(countError);
|
|
2438
2438
|
return j({ total: count ?? (data ?? []).length, sample: data ?? [] });
|
|
2439
2439
|
});
|
|
2440
|
+
// No approval preflight (#4550): nothing here rejects a missing target. An unknown site_id
|
|
2441
|
+
// returns an empty inactive list, and the approval binds that computed preview.
|
|
2440
2442
|
server.registerTool('identify_inactive', {
|
|
2441
2443
|
title: 'Identify Inactive',
|
|
2442
2444
|
annotations: {
|
|
@@ -2679,6 +2681,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2679
2681
|
// total_groups counts every group found; groups_truncated says when limit cut the list.
|
|
2680
2682
|
return j({ duplicate_groups: result, total_groups: groups.length, groups_truncated: groups.length > result.length, coverage });
|
|
2681
2683
|
});
|
|
2684
|
+
// No approval preflight (#4550): a missing target or source is not rejected. The preview uses
|
|
2685
|
+
// target?.email, and the post-approval path calls rpc merge_subscribers with no not-found refusal.
|
|
2682
2686
|
server.registerTool('merge_subscribers', {
|
|
2683
2687
|
title: 'Merge Subscribers',
|
|
2684
2688
|
annotations: {
|
package/build/tools/content.js
CHANGED
|
@@ -143,6 +143,48 @@ export function registerContentTools(server) {
|
|
|
143
143
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai';
|
|
144
144
|
return j({ asset_link: { ...data, url: `${appUrl.replace(/\/$/, '')}/a/${data.slug}` } });
|
|
145
145
|
});
|
|
146
|
+
server.registerTool('update_asset_link', {
|
|
147
|
+
title: 'Update Asset Link',
|
|
148
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
149
|
+
description: 'Update a project asset link or reversibly archive/restore it. Keeps its slug and view history. Requires approval in standard mode.',
|
|
150
|
+
inputSchema: {
|
|
151
|
+
asset_link_id: z.string().uuid(),
|
|
152
|
+
title: z.string().min(1).max(255).optional(),
|
|
153
|
+
asset_url: z.string().url().max(2000).optional(),
|
|
154
|
+
gate_headline: z.string().max(255).optional(),
|
|
155
|
+
gate_body: z.string().max(1000).optional(),
|
|
156
|
+
source_attribution: z.string().max(500).optional(),
|
|
157
|
+
status: z.enum(['draft', 'active', 'archived']).optional(),
|
|
158
|
+
approval_id: z.string().uuid().optional(),
|
|
159
|
+
},
|
|
160
|
+
}, withApproval('update_asset_link', async ({ asset_link_id, title, asset_url, gate_headline, gate_body, source_attribution, status }) => {
|
|
161
|
+
for (const [field, value] of Object.entries({ title, source_attribution, gate_headline, gate_body })) {
|
|
162
|
+
const error = refuseActiveTextFields(field, value);
|
|
163
|
+
if (error)
|
|
164
|
+
return j({ error, code: 'validation_failed' });
|
|
165
|
+
}
|
|
166
|
+
if (asset_url !== undefined) {
|
|
167
|
+
try {
|
|
168
|
+
assertHttpUrl(asset_url);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return j({ error: 'asset_url must use http or https', code: 'validation_failed' });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const patch = Object.fromEntries(Object.entries({ title, asset_url, gate_headline, gate_body, source_attribution, status })
|
|
175
|
+
.filter(([, value]) => value !== undefined));
|
|
176
|
+
if (!Object.keys(patch).length)
|
|
177
|
+
return j({ error: 'Provide at least one asset link field to update', code: 'validation_failed' });
|
|
178
|
+
const { data, error } = await db().from('asset_links').update({ ...patch, updated_at: new Date().toISOString() })
|
|
179
|
+
.eq('id', asset_link_id).eq('project_id', getProjectId()).select().maybeSingle();
|
|
180
|
+
if (error)
|
|
181
|
+
return j(dbToolError(error));
|
|
182
|
+
if (!data)
|
|
183
|
+
return j({ error: 'Asset link not found', code: 'NOT_FOUND' });
|
|
184
|
+
await logAudit({ action: 'asset_link.updated', resourceType: 'asset_link', resourceId: asset_link_id, details: patch });
|
|
185
|
+
const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
186
|
+
return j({ asset_link: { ...data, url: `${appUrl}/a/${data.slug}` } });
|
|
187
|
+
}));
|
|
146
188
|
server.registerTool('list_asset_links', {
|
|
147
189
|
title: 'List Asset Links',
|
|
148
190
|
annotations: {
|
package/build/tools/data.js
CHANGED
|
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { adminDb, db } from '../db.js';
|
|
5
5
|
import { decrypt, encrypt } from '../crypto.js';
|
|
6
6
|
import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
|
|
7
|
-
import { approvalPageUrl, assertChannelCanExecute, checkApproval, currentChannelContext, currentExecutingApprovalId, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
7
|
+
import { approvalPageUrl, assertChannelCanExecute, checkApproval, currentChannelContext, currentExecutingApprovalId, registerApprovalPreflight, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
8
8
|
import { getProjectId } from '../project.js';
|
|
9
9
|
import { logAudit } from '../audit.js';
|
|
10
10
|
import { dbToolError, deleteBlockedToolResult } from '../lib/db-error.js';
|
|
@@ -161,6 +161,82 @@ async function getPlatformProjectContext(client = adminDb(), orgId) {
|
|
|
161
161
|
throw new Error('organization has no Supabase-auth user to own the connection');
|
|
162
162
|
return { projectId, orgId: project.org_id, userId: userId };
|
|
163
163
|
}
|
|
164
|
+
/** Shared by the handler and the request-only preflight so the refusal text cannot drift. */
|
|
165
|
+
async function lookupSavedCredential(credentialId, orgId) {
|
|
166
|
+
const parsed = parseSavedCredentialId(credentialId);
|
|
167
|
+
if (!parsed)
|
|
168
|
+
return { ok: false, result: j({ error: 'Invalid credential_id' }) };
|
|
169
|
+
const client = adminDb();
|
|
170
|
+
let context;
|
|
171
|
+
try {
|
|
172
|
+
context = await getPlatformProjectContext(client, orgId);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
// Same catch body the handler used. These are business refusals, not a preflight outage.
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
result: j({ error: error instanceof Error ? dbToolError(error).error : 'Failed to delete provider credential' }),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const table = parsed.source === 'platform' ? 'platform_connections' : 'import_jobs';
|
|
182
|
+
const columns = parsed.source === 'platform'
|
|
183
|
+
? 'id, platform, display_name, status, is_active, created_at'
|
|
184
|
+
: 'id, provider, status, created_at, completed_at';
|
|
185
|
+
const { data: credential, error: credentialErr } = await client
|
|
186
|
+
.schema('email')
|
|
187
|
+
.from(table)
|
|
188
|
+
.select(columns)
|
|
189
|
+
.eq('id', parsed.rowId)
|
|
190
|
+
.eq('org_id', context.orgId)
|
|
191
|
+
.eq('project_id', context.projectId)
|
|
192
|
+
.maybeSingle();
|
|
193
|
+
if (credentialErr)
|
|
194
|
+
return { ok: false, result: j(dbToolError(credentialErr)), dbError: credentialErr };
|
|
195
|
+
if (!credential)
|
|
196
|
+
return { ok: false, result: j({ error: 'Credential not found' }) };
|
|
197
|
+
return { ok: true, parsed, credential, context, client };
|
|
198
|
+
}
|
|
199
|
+
async function findActiveCustomWebhook(connectionId) {
|
|
200
|
+
const client = adminDb();
|
|
201
|
+
const projectId = getProjectId();
|
|
202
|
+
const { data: connection, error: lookupError } = await client
|
|
203
|
+
.schema('email')
|
|
204
|
+
.from('platform_connections')
|
|
205
|
+
.select('id, project_id, display_name, config')
|
|
206
|
+
.eq('id', connectionId)
|
|
207
|
+
.eq('project_id', projectId)
|
|
208
|
+
.eq('platform', 'custom')
|
|
209
|
+
.eq('is_active', true)
|
|
210
|
+
.maybeSingle();
|
|
211
|
+
return { client, projectId, connection, lookupError };
|
|
212
|
+
}
|
|
213
|
+
// Confirmed is not consulted. The outer request-only gate runs before the handler's
|
|
214
|
+
// preview, so the target must exist in this project on the first call as well as on replay.
|
|
215
|
+
async function deleteProviderCredentialPreflight(params) {
|
|
216
|
+
const credentialId = typeof params.credential_id === 'string' ? params.credential_id : '';
|
|
217
|
+
const orgId = typeof params.org_id === 'string' ? params.org_id : undefined;
|
|
218
|
+
const found = await lookupSavedCredential(credentialId, orgId);
|
|
219
|
+
if (!found.ok) {
|
|
220
|
+
if (found.dbError)
|
|
221
|
+
throw found.dbError;
|
|
222
|
+
return found.result;
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
async function rotateCustomWebhookSecretPreflight(params) {
|
|
227
|
+
const connectionId = typeof params.connection_id === 'string' ? params.connection_id : '';
|
|
228
|
+
const { connection, lookupError } = await findActiveCustomWebhook(connectionId);
|
|
229
|
+
if (lookupError)
|
|
230
|
+
throw lookupError;
|
|
231
|
+
if (!connection)
|
|
232
|
+
return j({ error: 'Custom webhook not found' });
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
// Partial test doubles of action-approvals omit this export. Production always provides it.
|
|
236
|
+
if (typeof registerApprovalPreflight === 'function') {
|
|
237
|
+
registerApprovalPreflight('delete_provider_credential', deleteProviderCredentialPreflight);
|
|
238
|
+
registerApprovalPreflight('rotate_custom_webhook_secret', rotateCustomWebhookSecretPreflight);
|
|
239
|
+
}
|
|
164
240
|
function createCampaignMigrationTotals(options) {
|
|
165
241
|
return {
|
|
166
242
|
contacts: { total: options.includeContacts ? 1 : 0, processed: 0 },
|
|
@@ -401,27 +477,10 @@ export function registerDataTools(server) {
|
|
|
401
477
|
},
|
|
402
478
|
}, async ({ org_id, credential_id, confirmed, approval_id }) => {
|
|
403
479
|
try {
|
|
404
|
-
const
|
|
405
|
-
if (!
|
|
406
|
-
return
|
|
407
|
-
const client =
|
|
408
|
-
const context = await getPlatformProjectContext(client, org_id);
|
|
409
|
-
const table = parsed.source === 'platform' ? 'platform_connections' : 'import_jobs';
|
|
410
|
-
const columns = parsed.source === 'platform'
|
|
411
|
-
? 'id, platform, display_name, status, is_active, created_at'
|
|
412
|
-
: 'id, provider, status, created_at, completed_at';
|
|
413
|
-
const { data: credential, error: credentialErr } = await client
|
|
414
|
-
.schema('email')
|
|
415
|
-
.from(table)
|
|
416
|
-
.select(columns)
|
|
417
|
-
.eq('id', parsed.rowId)
|
|
418
|
-
.eq('org_id', context.orgId)
|
|
419
|
-
.eq('project_id', context.projectId)
|
|
420
|
-
.maybeSingle();
|
|
421
|
-
if (credentialErr)
|
|
422
|
-
return j(dbToolError(credentialErr));
|
|
423
|
-
if (!credential)
|
|
424
|
-
return j({ error: 'Credential not found' });
|
|
480
|
+
const found = await lookupSavedCredential(credential_id, org_id);
|
|
481
|
+
if (!found.ok)
|
|
482
|
+
return found.result;
|
|
483
|
+
const { parsed, credential, context, client } = found;
|
|
425
484
|
const provider = parsed.source === 'platform' ? credential.platform : credential.provider;
|
|
426
485
|
const approvalParams = {
|
|
427
486
|
credential_id,
|
|
@@ -870,21 +929,12 @@ export function registerDataTools(server) {
|
|
|
870
929
|
},
|
|
871
930
|
}, async ({ connection_id, confirmed, approval_id }) => {
|
|
872
931
|
try {
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
.from('platform_connections')
|
|
878
|
-
.select('id, project_id, display_name, config')
|
|
879
|
-
.eq('id', connection_id)
|
|
880
|
-
.eq('project_id', projectId)
|
|
881
|
-
.eq('platform', 'custom')
|
|
882
|
-
.eq('is_active', true)
|
|
883
|
-
.maybeSingle();
|
|
884
|
-
if (lookupError)
|
|
885
|
-
return j(dbToolError(lookupError));
|
|
886
|
-
if (!connection)
|
|
932
|
+
const lookedUp = await findActiveCustomWebhook(connection_id);
|
|
933
|
+
if (lookedUp.lookupError)
|
|
934
|
+
return j(dbToolError(lookedUp.lookupError));
|
|
935
|
+
if (!lookedUp.connection)
|
|
887
936
|
return j({ error: 'Custom webhook not found' });
|
|
937
|
+
const { client, projectId, connection } = lookedUp;
|
|
888
938
|
if (!confirmed) {
|
|
889
939
|
return j({
|
|
890
940
|
confirmation_required: true,
|
package/build/tools/forms.js
CHANGED
|
@@ -2,8 +2,29 @@ import { z } from 'zod';
|
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
import { getProjectId } from '../project.js';
|
|
4
4
|
import { assertHttpUrl } from '../lib/url-validation.js';
|
|
5
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
6
|
+
import { logAudit } from '../audit.js';
|
|
5
7
|
import { dbToolError } from '../lib/db-error.js';
|
|
6
8
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
9
|
+
function validateFields(fields) {
|
|
10
|
+
if (!fields.length)
|
|
11
|
+
return 'fields must contain at least one field';
|
|
12
|
+
const names = new Set();
|
|
13
|
+
for (const field of fields) {
|
|
14
|
+
if (typeof field.name !== 'string' || !/^[a-z][a-z0-9_]{0,49}$/.test(field.name) ||
|
|
15
|
+
typeof field.type !== 'string' || !['email', 'text', 'textarea', 'select', 'checkbox', 'number', 'tel', 'url', 'date'].includes(field.type)) {
|
|
16
|
+
return 'Each field needs a name (lowercase snake_case) and a supported type';
|
|
17
|
+
}
|
|
18
|
+
if (names.has(field.name))
|
|
19
|
+
return `Duplicate field name: ${field.name}`;
|
|
20
|
+
names.add(field.name);
|
|
21
|
+
}
|
|
22
|
+
// The public handler always reads fields.email to identify the subscriber.
|
|
23
|
+
if (!fields.some(field => field.name === 'email' && field.type === 'email')) {
|
|
24
|
+
return 'fields must include an email field named email';
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
7
28
|
export function registerFormTools(server) {
|
|
8
29
|
server.registerTool('list_forms', {
|
|
9
30
|
title: 'List Forms',
|
|
@@ -70,22 +91,9 @@ export function registerFormTools(server) {
|
|
|
70
91
|
form_type: z.enum(['signup', 'management']).optional(),
|
|
71
92
|
},
|
|
72
93
|
}, async ({ site_id, name, fields, redirect_url, double_opt_in, form_type }) => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
for (const field of fields) {
|
|
77
|
-
if (typeof field.name !== 'string' || !/^[a-z][a-z0-9_]{0,49}$/.test(field.name) ||
|
|
78
|
-
typeof field.type !== 'string' || !['email', 'text', 'textarea', 'select', 'checkbox', 'number', 'tel', 'url', 'date'].includes(field.type)) {
|
|
79
|
-
return j({ error: 'Each field needs a name (lowercase snake_case) and a supported type', code: 'validation_failed' });
|
|
80
|
-
}
|
|
81
|
-
if (names.has(field.name))
|
|
82
|
-
return j({ error: `Duplicate field name: ${field.name}`, code: 'validation_failed' });
|
|
83
|
-
names.add(field.name);
|
|
84
|
-
}
|
|
85
|
-
// The public handler always reads fields.email to identify the subscriber.
|
|
86
|
-
if (!fields.some(field => field.name === 'email' && field.type === 'email')) {
|
|
87
|
-
return j({ error: 'fields must include an email field named email', code: 'validation_failed' });
|
|
88
|
-
}
|
|
94
|
+
const fieldsError = validateFields(fields);
|
|
95
|
+
if (fieldsError)
|
|
96
|
+
return j({ error: fieldsError, code: 'validation_failed' });
|
|
89
97
|
if (redirect_url !== undefined) {
|
|
90
98
|
try {
|
|
91
99
|
assertHttpUrl(redirect_url);
|
|
@@ -105,4 +113,87 @@ export function registerFormTools(server) {
|
|
|
105
113
|
return j({ ...dbToolError(error), code: 'INTERNAL_ERROR' });
|
|
106
114
|
return j({ form: data });
|
|
107
115
|
});
|
|
116
|
+
server.registerTool('update_form', {
|
|
117
|
+
title: 'Update Form',
|
|
118
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
119
|
+
description: 'Update a project form without changing its slug or submissions. Settings edits preserve other settings. Requires approval in standard mode.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
form_id: z.string().uuid(),
|
|
122
|
+
name: z.string().min(1).optional(),
|
|
123
|
+
fields: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
124
|
+
redirect_url: z.string().url().optional(),
|
|
125
|
+
double_opt_in: z.boolean().optional(),
|
|
126
|
+
status: z.enum(['active', 'archived']).optional(),
|
|
127
|
+
approval_id: z.string().uuid().optional(),
|
|
128
|
+
},
|
|
129
|
+
}, withApproval('update_form', async ({ form_id, name, fields, redirect_url, double_opt_in, status }) => {
|
|
130
|
+
if (fields !== undefined) {
|
|
131
|
+
const error = validateFields(fields);
|
|
132
|
+
if (error)
|
|
133
|
+
return j({ error, code: 'validation_failed' });
|
|
134
|
+
}
|
|
135
|
+
if (redirect_url !== undefined) {
|
|
136
|
+
try {
|
|
137
|
+
assertHttpUrl(redirect_url);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return j({ error: 'redirect_url must use http or https', code: 'validation_failed' });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const projectId = getProjectId();
|
|
144
|
+
const { data: form, error: readError } = await db().from('signup_forms').select('id, settings')
|
|
145
|
+
.eq('id', form_id).eq('project_id', projectId).maybeSingle();
|
|
146
|
+
if (readError)
|
|
147
|
+
return j(dbToolError(readError));
|
|
148
|
+
if (!form)
|
|
149
|
+
return j({ error: 'Form not found', code: 'NOT_FOUND' });
|
|
150
|
+
const patch = {};
|
|
151
|
+
if (name !== undefined)
|
|
152
|
+
patch.name = name;
|
|
153
|
+
if (fields !== undefined)
|
|
154
|
+
patch.fields = fields;
|
|
155
|
+
if (status !== undefined)
|
|
156
|
+
patch.status = status;
|
|
157
|
+
const settingsChanged = redirect_url !== undefined || double_opt_in !== undefined;
|
|
158
|
+
if (settingsChanged) {
|
|
159
|
+
patch.settings = { ...form.settings,
|
|
160
|
+
...(redirect_url !== undefined ? { redirect_url } : {}),
|
|
161
|
+
...(double_opt_in !== undefined ? { double_opt_in } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (!Object.keys(patch).length)
|
|
165
|
+
return j({ error: 'Provide at least one form field to update', code: 'validation_failed' });
|
|
166
|
+
patch.updated_at = new Date().toISOString();
|
|
167
|
+
let query = db().from('signup_forms').update(patch).eq('id', form_id).eq('project_id', projectId);
|
|
168
|
+
// Do not overwrite a concurrent settings edit with the earlier JSON snapshot.
|
|
169
|
+
if (settingsChanged)
|
|
170
|
+
query = query.eq('settings', JSON.stringify(form.settings));
|
|
171
|
+
const { data, error } = await query.select().maybeSingle();
|
|
172
|
+
if (error)
|
|
173
|
+
return j(dbToolError(error));
|
|
174
|
+
if (!data)
|
|
175
|
+
return j({ error: 'Form changed; read it again before retrying', code: 'conflict' });
|
|
176
|
+
await logAudit({ action: 'form.updated', resourceType: 'form', resourceId: form_id, details: patch });
|
|
177
|
+
return j({ form: data });
|
|
178
|
+
}));
|
|
179
|
+
server.registerTool('archive_form', {
|
|
180
|
+
title: 'Archive Form',
|
|
181
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
182
|
+
description: 'Reversibly archive a project form, or restore it with archived=false. Keeps submissions. Requires approval in standard mode.',
|
|
183
|
+
inputSchema: {
|
|
184
|
+
form_id: z.string().uuid(),
|
|
185
|
+
archived: z.boolean().optional().default(true),
|
|
186
|
+
approval_id: z.string().uuid().optional(),
|
|
187
|
+
},
|
|
188
|
+
}, withApproval('archive_form', async ({ form_id, archived = true }) => {
|
|
189
|
+
const status = archived ? 'archived' : 'active';
|
|
190
|
+
const { data, error } = await db().from('signup_forms').update({ status, updated_at: new Date().toISOString() })
|
|
191
|
+
.eq('id', form_id).eq('project_id', getProjectId()).select().maybeSingle();
|
|
192
|
+
if (error)
|
|
193
|
+
return j(dbToolError(error));
|
|
194
|
+
if (!data)
|
|
195
|
+
return j({ error: 'Form not found', code: 'NOT_FOUND' });
|
|
196
|
+
await logAudit({ action: 'form.updated', resourceType: 'form', resourceId: form_id, details: { status } });
|
|
197
|
+
return j({ form: data });
|
|
198
|
+
}));
|
|
108
199
|
}
|
package/build/tools/gdpr.js
CHANGED
|
@@ -3,11 +3,81 @@ import { z } from 'zod';
|
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { logAudit } from '../audit.js';
|
|
6
|
-
import { approvalSatisfied, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
|
|
6
|
+
import { approvalSatisfied, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, registerApprovalPreflight } from '../lib/action-approvals.js';
|
|
7
7
|
import { dbToolError, deleteBlockedToolResult } from '../lib/db-error.js';
|
|
8
8
|
import { subscriberBlockers } from '../lib/delete-blockers.js';
|
|
9
9
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
10
10
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
11
|
+
async function findSubscriberForDeletion(contactId) {
|
|
12
|
+
const { data, error } = await db()
|
|
13
|
+
.from('subscribers')
|
|
14
|
+
.select('id, email')
|
|
15
|
+
.eq('id', contactId)
|
|
16
|
+
.eq('project_id', getProjectId())
|
|
17
|
+
.maybeSingle();
|
|
18
|
+
return { contact: (data ?? null), error };
|
|
19
|
+
}
|
|
20
|
+
// A database error is not "missing". The handler still maps it to the old not-found text;
|
|
21
|
+
// the preflight throws so the registration boundary reports internal_error and writes no approval.
|
|
22
|
+
async function deleteSubscriberDataPreflight(params) {
|
|
23
|
+
const contactId = typeof params.contact_id === 'string' ? params.contact_id : '';
|
|
24
|
+
const { contact, error } = await findSubscriberForDeletion(contactId);
|
|
25
|
+
if (error)
|
|
26
|
+
throw error;
|
|
27
|
+
if (!contact)
|
|
28
|
+
return txt('Contact not found or does not belong to this project.');
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
async function loadCcpaRequest(requestId) {
|
|
32
|
+
const projectId = getProjectId();
|
|
33
|
+
const { data, error } = await db()
|
|
34
|
+
.from('compliance_requests')
|
|
35
|
+
.select('id, email, request_type, status, expires_at')
|
|
36
|
+
.eq('id', requestId)
|
|
37
|
+
.eq('project_id', projectId)
|
|
38
|
+
.eq('regulation', 'ccpa')
|
|
39
|
+
.maybeSingle();
|
|
40
|
+
return { projectId, request: (data ?? null), error };
|
|
41
|
+
}
|
|
42
|
+
function ccpaNotActionable(request) {
|
|
43
|
+
if (!request)
|
|
44
|
+
return 'CCPA request not found or does not belong to this project.';
|
|
45
|
+
if (request.status === 'completed' || request.status === 'expired') {
|
|
46
|
+
return `Request is already "${request.status}" and cannot be actioned further.`;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
function ccpaNotReadyToComplete(request) {
|
|
51
|
+
if (request.status !== 'verified') {
|
|
52
|
+
return `Request must be "verified" before it can be completed (currently "${request.status}"). Call this tool with status: "verified" first.`;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
// Only completed + delete requests the hand-written approval. status:'verified' updates the row
|
|
57
|
+
// with no approval, and a non-delete completion does too. approvalSatisfied can skip the gate
|
|
58
|
+
// only after those refusals, so this preflight refuses the same rows the handler refuses first.
|
|
59
|
+
async function actionCcpaRequestPreflight(params) {
|
|
60
|
+
if (params.status !== 'completed')
|
|
61
|
+
return undefined;
|
|
62
|
+
const requestId = typeof params.request_id === 'string' ? params.request_id : '';
|
|
63
|
+
const { request, error } = await loadCcpaRequest(requestId);
|
|
64
|
+
if (error)
|
|
65
|
+
throw error;
|
|
66
|
+
const closed = ccpaNotActionable(request);
|
|
67
|
+
if (closed)
|
|
68
|
+
return txt(closed);
|
|
69
|
+
const notReady = ccpaNotReadyToComplete(request);
|
|
70
|
+
if (notReady)
|
|
71
|
+
return txt(notReady);
|
|
72
|
+
if (request.request_type !== 'delete')
|
|
73
|
+
return undefined;
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
// Partial test doubles of action-approvals omit this export. Production always provides it.
|
|
77
|
+
if (typeof registerApprovalPreflight === 'function') {
|
|
78
|
+
registerApprovalPreflight('delete_subscriber_data', deleteSubscriberDataPreflight);
|
|
79
|
+
registerApprovalPreflight('action_ccpa_request', actionCcpaRequestPreflight);
|
|
80
|
+
}
|
|
11
81
|
export function registerGdprTools(server) {
|
|
12
82
|
server.registerTool('delete_subscriber_data', {
|
|
13
83
|
title: 'Delete Subscriber Data',
|
|
@@ -24,13 +94,9 @@ export function registerGdprTools(server) {
|
|
|
24
94
|
approval_id: z.string().uuid().optional().describe('Required — get this by calling once without it, then have a human approve via dashboard.'),
|
|
25
95
|
},
|
|
26
96
|
}, async ({ contact_id, reason, approval_id }) => {
|
|
27
|
-
// Verify contact belongs to this project (defense in depth)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
.select('id, email')
|
|
31
|
-
.eq('id', contact_id)
|
|
32
|
-
.eq('project_id', getProjectId())
|
|
33
|
-
.maybeSingle();
|
|
97
|
+
// Verify contact belongs to this project (defense in depth). A lookup error stays
|
|
98
|
+
// the historical not-found text; the registered preflight reports it as internal_error.
|
|
99
|
+
const { contact } = await findSubscriberForDeletion(contact_id);
|
|
34
100
|
if (!contact)
|
|
35
101
|
return txt('Contact not found or does not belong to this project.');
|
|
36
102
|
const approvalParams = { contact_id, email: contact.email, reason };
|
|
@@ -132,21 +198,14 @@ export function registerGdprTools(server) {
|
|
|
132
198
|
approval_id: z.string().uuid().optional().describe('Required to complete a delete request after a human approves the summary in the dashboard.'),
|
|
133
199
|
},
|
|
134
200
|
}, async ({ request_id, status, approval_id }) => {
|
|
135
|
-
const projectId =
|
|
136
|
-
const { data: request, error: fetchErr } = await db()
|
|
137
|
-
.from('compliance_requests')
|
|
138
|
-
.select('id, email, request_type, status, expires_at')
|
|
139
|
-
.eq('id', request_id)
|
|
140
|
-
.eq('project_id', projectId)
|
|
141
|
-
.eq('regulation', 'ccpa')
|
|
142
|
-
.maybeSingle();
|
|
201
|
+
const { projectId, request, error: fetchErr } = await loadCcpaRequest(request_id);
|
|
143
202
|
if (fetchErr)
|
|
144
203
|
return txt(`Error: ${dbToolError(fetchErr).error}`);
|
|
204
|
+
const closed = ccpaNotActionable(request);
|
|
205
|
+
if (closed)
|
|
206
|
+
return txt(closed);
|
|
145
207
|
if (!request)
|
|
146
208
|
return txt('CCPA request not found or does not belong to this project.');
|
|
147
|
-
if (request.status === 'completed' || request.status === 'expired') {
|
|
148
|
-
return txt(`Request is already "${request.status}" and cannot be actioned further.`);
|
|
149
|
-
}
|
|
150
209
|
if (status === 'verified') {
|
|
151
210
|
if (request.status !== 'pending') {
|
|
152
211
|
return txt(`Request must be "pending" to verify (currently "${request.status}").`);
|
|
@@ -177,9 +236,9 @@ export function registerGdprTools(server) {
|
|
|
177
236
|
});
|
|
178
237
|
return j({ ok: true, request: data });
|
|
179
238
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
239
|
+
const notReady = ccpaNotReadyToComplete(request);
|
|
240
|
+
if (notReady)
|
|
241
|
+
return txt(notReady);
|
|
183
242
|
if (request.request_type === 'delete') {
|
|
184
243
|
const approvalParams = {
|
|
185
244
|
request_id,
|
package/build/tools/org.js
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { logAudit } from '../audit.js';
|
|
4
4
|
import { adminDb, db } from '../db.js';
|
|
5
5
|
import { getProjectId } from '../project.js';
|
|
6
|
-
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
6
|
+
import { assertChannelCanExecute, checkApproval, currentChannelContext, registerApprovalPreflight, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
7
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
8
|
import { dbToolError } from '../lib/db-error.js';
|
|
9
9
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
@@ -62,7 +62,7 @@ async function resolveCurrentOrgId(requestedOrgId) {
|
|
|
62
62
|
.eq('id', getProjectId())
|
|
63
63
|
.maybeSingle();
|
|
64
64
|
if (error)
|
|
65
|
-
return { ok: false, ...dbToolError(error) };
|
|
65
|
+
return { ok: false, ...dbToolError(error), cause: error };
|
|
66
66
|
if (!project?.org_id)
|
|
67
67
|
return { ok: false, error: 'Project has no organization', code: 'NO_ORG' };
|
|
68
68
|
if (requestedOrgId && requestedOrgId !== project.org_id) {
|
|
@@ -70,6 +70,46 @@ async function resolveCurrentOrgId(requestedOrgId) {
|
|
|
70
70
|
}
|
|
71
71
|
return { ok: true, orgId: project.org_id };
|
|
72
72
|
}
|
|
73
|
+
/** Shared by the handler and the request-only preflight so the refusal text cannot drift. */
|
|
74
|
+
async function lookupOrgMember(userId, orgId) {
|
|
75
|
+
const context = await resolveCurrentOrgId(orgId);
|
|
76
|
+
if (!context.ok) {
|
|
77
|
+
const dbError = 'cause' in context ? context.cause : undefined;
|
|
78
|
+
return { ok: false, result: j({ error: context.error, code: context.code }), dbError };
|
|
79
|
+
}
|
|
80
|
+
const { data: member, error: memberErr } = await db()
|
|
81
|
+
.from('org_members')
|
|
82
|
+
.select('id, user_id, role')
|
|
83
|
+
.eq('org_id', context.orgId)
|
|
84
|
+
.eq('user_id', userId)
|
|
85
|
+
.maybeSingle();
|
|
86
|
+
if (memberErr)
|
|
87
|
+
return { ok: false, result: j(dbToolError(memberErr)), dbError: memberErr };
|
|
88
|
+
if (!member)
|
|
89
|
+
return { ok: false, result: j({ error: 'Member not found', code: 'NOT_FOUND' }) };
|
|
90
|
+
return { ok: true, orgId: context.orgId, member: member };
|
|
91
|
+
}
|
|
92
|
+
function preflightOrgId(params) {
|
|
93
|
+
return typeof params.org_id === 'string' ? params.org_id : undefined;
|
|
94
|
+
}
|
|
95
|
+
// Existence and org ownership are checked whether or not confirmed is present (schema default
|
|
96
|
+
// false). The last-owner refusal runs only after confirmed=true in the handler. Applying it
|
|
97
|
+
// here would refuse an unconfirmed request-only call that currently receives an approval request.
|
|
98
|
+
async function teamMemberTargetPreflight(params) {
|
|
99
|
+
const userId = typeof params.user_id === 'string' ? params.user_id : '';
|
|
100
|
+
const found = await lookupOrgMember(userId, preflightOrgId(params));
|
|
101
|
+
if (!found.ok) {
|
|
102
|
+
if (found.dbError)
|
|
103
|
+
throw found.dbError;
|
|
104
|
+
return found.result;
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
// Partial test doubles of action-approvals omit this export. Production always provides it.
|
|
109
|
+
if (typeof registerApprovalPreflight === 'function') {
|
|
110
|
+
registerApprovalPreflight('remove_team_member', teamMemberTargetPreflight);
|
|
111
|
+
registerApprovalPreflight('set_team_member_role', teamMemberTargetPreflight);
|
|
112
|
+
}
|
|
73
113
|
function normalizeHost(host) {
|
|
74
114
|
if (!host)
|
|
75
115
|
return null;
|
|
@@ -208,33 +248,24 @@ export function registerOrgTools(server) {
|
|
|
208
248
|
approval_id: z.string().uuid().optional().describe('Approval token from a previous confirmed call without approval_id. Required when the action is not autonomous.'),
|
|
209
249
|
},
|
|
210
250
|
}, async ({ user_id, org_id, confirmed, approval_id }) => {
|
|
211
|
-
const
|
|
212
|
-
if (!
|
|
213
|
-
return
|
|
214
|
-
const {
|
|
215
|
-
|
|
216
|
-
.select('id, user_id, role')
|
|
217
|
-
.eq('org_id', context.orgId)
|
|
218
|
-
.eq('user_id', user_id)
|
|
219
|
-
.maybeSingle();
|
|
220
|
-
if (memberErr)
|
|
221
|
-
return j(dbToolError(memberErr));
|
|
222
|
-
if (!member)
|
|
223
|
-
return j({ error: 'Member not found', code: 'NOT_FOUND' });
|
|
224
|
-
const approvalParams = { user_id, org_id: context.orgId };
|
|
251
|
+
const found = await lookupOrgMember(user_id, org_id);
|
|
252
|
+
if (!found.ok)
|
|
253
|
+
return found.result;
|
|
254
|
+
const { orgId, member } = found;
|
|
255
|
+
const approvalParams = { user_id, org_id: orgId };
|
|
225
256
|
if (!confirmed) {
|
|
226
257
|
return j({
|
|
227
258
|
confirmation_required: true,
|
|
228
259
|
tool: 'remove_team_member',
|
|
229
260
|
member: { user_id: member.user_id, role: member.role },
|
|
230
|
-
message: `Removing this member (role: ${member.role}) revokes their access to org ${
|
|
261
|
+
message: `Removing this member (role: ${member.role}) revokes their access to org ${orgId}. Call again with confirmed=true to proceed.`,
|
|
231
262
|
});
|
|
232
263
|
}
|
|
233
264
|
if (member.role === 'owner') {
|
|
234
265
|
const { count } = await db()
|
|
235
266
|
.from('org_members')
|
|
236
267
|
.select('id', { count: 'exact', head: true })
|
|
237
|
-
.eq('org_id',
|
|
268
|
+
.eq('org_id', orgId)
|
|
238
269
|
.eq('role', 'owner');
|
|
239
270
|
if ((count ?? 0) <= 1)
|
|
240
271
|
return j({ error: 'Cannot remove the last owner', code: 'LAST_OWNER' });
|
|
@@ -257,7 +288,7 @@ export function registerOrgTools(server) {
|
|
|
257
288
|
const { error } = await db()
|
|
258
289
|
.from('org_members')
|
|
259
290
|
.delete()
|
|
260
|
-
.eq('org_id',
|
|
291
|
+
.eq('org_id', orgId)
|
|
261
292
|
.eq('user_id', user_id);
|
|
262
293
|
if (error)
|
|
263
294
|
return j(dbToolError(error));
|
|
@@ -265,7 +296,7 @@ export function registerOrgTools(server) {
|
|
|
265
296
|
action: 'remove_member',
|
|
266
297
|
resourceType: 'org_member',
|
|
267
298
|
resourceId: member.id,
|
|
268
|
-
details: { org_id:
|
|
299
|
+
details: { org_id: orgId, removed_user: user_id, role: member.role },
|
|
269
300
|
}).catch(() => { });
|
|
270
301
|
return j({ ok: true, removed_user_id: user_id });
|
|
271
302
|
});
|
|
@@ -286,20 +317,11 @@ export function registerOrgTools(server) {
|
|
|
286
317
|
approval_id: z.string().uuid().optional(),
|
|
287
318
|
},
|
|
288
319
|
}, async ({ user_id, role, org_id, confirmed, approval_id }) => {
|
|
289
|
-
const
|
|
290
|
-
if (!
|
|
291
|
-
return
|
|
292
|
-
const {
|
|
293
|
-
|
|
294
|
-
.select('id, user_id, role')
|
|
295
|
-
.eq('org_id', context.orgId)
|
|
296
|
-
.eq('user_id', user_id)
|
|
297
|
-
.maybeSingle();
|
|
298
|
-
if (memberErr)
|
|
299
|
-
return j(dbToolError(memberErr));
|
|
300
|
-
if (!member)
|
|
301
|
-
return j({ error: 'Member not found', code: 'NOT_FOUND' });
|
|
302
|
-
const approvalParams = { user_id, role, org_id: context.orgId };
|
|
320
|
+
const found = await lookupOrgMember(user_id, org_id);
|
|
321
|
+
if (!found.ok)
|
|
322
|
+
return found.result;
|
|
323
|
+
const { orgId, member } = found;
|
|
324
|
+
const approvalParams = { user_id, role, org_id: orgId };
|
|
303
325
|
if (!confirmed) {
|
|
304
326
|
return j({
|
|
305
327
|
confirmation_required: true,
|
|
@@ -326,7 +348,7 @@ export function registerOrgTools(server) {
|
|
|
326
348
|
const { data, error } = await db()
|
|
327
349
|
.from('org_members')
|
|
328
350
|
.update({ role })
|
|
329
|
-
.eq('org_id',
|
|
351
|
+
.eq('org_id', orgId)
|
|
330
352
|
.eq('user_id', user_id)
|
|
331
353
|
.select('user_id, role')
|
|
332
354
|
.maybeSingle();
|
|
@@ -336,7 +358,7 @@ export function registerOrgTools(server) {
|
|
|
336
358
|
action: 'update_member_role',
|
|
337
359
|
resourceType: 'org_member',
|
|
338
360
|
resourceId: member.id,
|
|
339
|
-
details: { org_id:
|
|
361
|
+
details: { org_id: orgId, user_id, new_role: role },
|
|
340
362
|
}).catch(() => { });
|
|
341
363
|
return j({ member: data });
|
|
342
364
|
});
|
|
@@ -3,6 +3,8 @@ import { z } from 'zod';
|
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { assertHttpUrl, RESERVED_LINK_SLUGS } from '../lib/url-validation.js';
|
|
6
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
7
|
+
import { logAudit } from '../audit.js';
|
|
6
8
|
import { dbToolError } from '../lib/db-error.js';
|
|
7
9
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
8
10
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
@@ -85,6 +87,42 @@ export function registerShortLinkTools(server) {
|
|
|
85
87
|
return j({ ...dbToolError(error), code: 'INTERNAL_ERROR' });
|
|
86
88
|
return j({ short_link: data, short_url: `${appBaseUrl()}/s/${data.slug}` });
|
|
87
89
|
});
|
|
90
|
+
server.registerTool('update_short_link', {
|
|
91
|
+
title: 'Update Short Link',
|
|
92
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
93
|
+
description: 'Update a project short link or reversibly disable/re-enable it. Keeps click history. Requires approval in standard mode.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
short_link_id: z.string().uuid(),
|
|
96
|
+
destination_url: z.string().url().optional(),
|
|
97
|
+
disabled: z.boolean().optional(),
|
|
98
|
+
reason: z.string().optional(),
|
|
99
|
+
approval_id: z.string().uuid().optional(),
|
|
100
|
+
},
|
|
101
|
+
}, withApproval('update_short_link', async ({ short_link_id, destination_url, disabled, reason }) => {
|
|
102
|
+
const patch = {};
|
|
103
|
+
if (destination_url !== undefined) {
|
|
104
|
+
try {
|
|
105
|
+
patch.destination_url = assertHttpUrl(destination_url);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return j({ error: 'destination_url must use http or https', code: 'validation_failed' });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (disabled !== undefined) {
|
|
112
|
+
patch.disabled_at = disabled ? new Date().toISOString() : null;
|
|
113
|
+
patch.disabled_reason = disabled ? reason ?? null : null;
|
|
114
|
+
}
|
|
115
|
+
if (!Object.keys(patch).length)
|
|
116
|
+
return j({ error: 'Provide destination_url or disabled', code: 'validation_failed' });
|
|
117
|
+
const { data, error } = await db().from('short_links').update(patch)
|
|
118
|
+
.eq('id', short_link_id).eq('project_id', getProjectId()).select().maybeSingle();
|
|
119
|
+
if (error)
|
|
120
|
+
return j(dbToolError(error));
|
|
121
|
+
if (!data)
|
|
122
|
+
return j({ error: 'Short link not found', code: 'NOT_FOUND' });
|
|
123
|
+
await logAudit({ action: 'short_link.updated', resourceType: 'short_link', resourceId: short_link_id, details: patch });
|
|
124
|
+
return j({ short_link: data, short_url: `${appBaseUrl()}/s/${data.slug}` });
|
|
125
|
+
}));
|
|
88
126
|
server.registerTool('get_short_link_stats', {
|
|
89
127
|
title: 'Get Short Link Stats',
|
|
90
128
|
annotations: {
|
|
@@ -99,7 +137,7 @@ export function registerShortLinkTools(server) {
|
|
|
99
137
|
const projectId = getProjectId();
|
|
100
138
|
const { data: link, error: linkError } = await db()
|
|
101
139
|
.from('short_links')
|
|
102
|
-
.select('id, slug, destination_url, click_count, last_clicked_at, created_at')
|
|
140
|
+
.select('id, slug, destination_url, click_count, last_clicked_at, created_at, disabled_at, disabled_reason')
|
|
103
141
|
.eq('id', short_link_id)
|
|
104
142
|
.eq('project_id', projectId)
|
|
105
143
|
.maybeSingle();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.215",
|
|
4
4
|
"description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
|
|
5
5
|
"mcpName": "ai.sendinel/mcp-server",
|
|
6
6
|
"type": "module",
|