@agentic-kit/db-tools 0.2.0
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/LICENSE +23 -0
- package/README.md +138 -0
- package/app-workspace.d.ts +16 -0
- package/app-workspace.js +145 -0
- package/context.d.ts +62 -0
- package/context.js +282 -0
- package/db-probe.d.ts +30 -0
- package/db-probe.js +50 -0
- package/esm/app-workspace.d.ts +16 -0
- package/esm/app-workspace.js +134 -0
- package/esm/context.d.ts +62 -0
- package/esm/context.js +270 -0
- package/esm/db-probe.d.ts +30 -0
- package/esm/db-probe.js +47 -0
- package/esm/host.d.ts +115 -0
- package/esm/host.js +23 -0
- package/esm/index.d.ts +20 -0
- package/esm/index.js +52 -0
- package/esm/policy/add-policies-to-table.d.ts +26 -0
- package/esm/policy/add-policies-to-table.js +66 -0
- package/esm/policy/provision-helpers.d.ts +38 -0
- package/esm/policy/provision-helpers.js +50 -0
- package/esm/provision-database/credential.d.ts +14 -0
- package/esm/provision-database/credential.js +19 -0
- package/esm/provision-database/env-file.d.ts +37 -0
- package/esm/provision-database/env-file.js +85 -0
- package/esm/provision-database/manifest.d.ts +10 -0
- package/esm/provision-database/manifest.js +48 -0
- package/esm/provision-database/pg-fixups.d.ts +16 -0
- package/esm/provision-database/pg-fixups.js +132 -0
- package/esm/provision-database/preset-match.d.ts +13 -0
- package/esm/provision-database/preset-match.js +66 -0
- package/esm/provision-database/presets.d.ts +4 -0
- package/esm/provision-database/presets.js +13 -0
- package/esm/provision-database/request-database.d.ts +61 -0
- package/esm/provision-database/request-database.js +130 -0
- package/esm/provision-database/resolve.d.ts +27 -0
- package/esm/provision-database/resolve.js +49 -0
- package/esm/records/meta.d.ts +41 -0
- package/esm/records/meta.js +167 -0
- package/esm/run-codegen/barrels.d.ts +3 -0
- package/esm/run-codegen/barrels.js +48 -0
- package/esm/run-codegen/endpoints.d.ts +8 -0
- package/esm/run-codegen/endpoints.js +46 -0
- package/esm/schema-resolve.d.ts +23 -0
- package/esm/schema-resolve.js +69 -0
- package/esm/tool-schema.d.ts +3 -0
- package/esm/tool-schema.js +10 -0
- package/esm/tools/add-policies.d.ts +24 -0
- package/esm/tools/add-policies.js +94 -0
- package/esm/tools/add-records.d.ts +18 -0
- package/esm/tools/add-records.js +122 -0
- package/esm/tools/add-relation-schema.d.ts +29 -0
- package/esm/tools/add-relation-schema.js +56 -0
- package/esm/tools/add-relation.d.ts +10 -0
- package/esm/tools/add-relation.js +91 -0
- package/esm/tools/create-api-key.d.ts +25 -0
- package/esm/tools/create-api-key.js +214 -0
- package/esm/tools/describe-schema.d.ts +19 -0
- package/esm/tools/describe-schema.js +130 -0
- package/esm/tools/manage-entity-types.d.ts +40 -0
- package/esm/tools/manage-entity-types.js +201 -0
- package/esm/tools/mutations.d.ts +35 -0
- package/esm/tools/mutations.js +230 -0
- package/esm/tools/provision-blueprint.d.ts +21 -0
- package/esm/tools/provision-blueprint.js +109 -0
- package/esm/tools/provision-database.d.ts +18 -0
- package/esm/tools/provision-database.js +260 -0
- package/esm/tools/run-codegen.d.ts +12 -0
- package/esm/tools/run-codegen.js +137 -0
- package/esm/tools/templates.d.ts +71 -0
- package/esm/tools/templates.js +331 -0
- package/host.d.ts +115 -0
- package/host.js +28 -0
- package/index.d.ts +20 -0
- package/index.js +76 -0
- package/package.json +49 -0
- package/policy/add-policies-to-table.d.ts +26 -0
- package/policy/add-policies-to-table.js +71 -0
- package/policy/provision-helpers.d.ts +38 -0
- package/policy/provision-helpers.js +58 -0
- package/provision-database/credential.d.ts +14 -0
- package/provision-database/credential.js +22 -0
- package/provision-database/env-file.d.ts +37 -0
- package/provision-database/env-file.js +91 -0
- package/provision-database/manifest.d.ts +10 -0
- package/provision-database/manifest.js +56 -0
- package/provision-database/pg-fixups.d.ts +16 -0
- package/provision-database/pg-fixups.js +170 -0
- package/provision-database/preset-match.d.ts +13 -0
- package/provision-database/preset-match.js +70 -0
- package/provision-database/presets.d.ts +4 -0
- package/provision-database/presets.js +17 -0
- package/provision-database/request-database.d.ts +61 -0
- package/provision-database/request-database.js +134 -0
- package/provision-database/resolve.d.ts +27 -0
- package/provision-database/resolve.js +53 -0
- package/records/meta.d.ts +41 -0
- package/records/meta.js +172 -0
- package/run-codegen/barrels.d.ts +3 -0
- package/run-codegen/barrels.js +54 -0
- package/run-codegen/endpoints.d.ts +8 -0
- package/run-codegen/endpoints.js +51 -0
- package/schema-resolve.d.ts +23 -0
- package/schema-resolve.js +75 -0
- package/tool-schema.d.ts +3 -0
- package/tool-schema.js +13 -0
- package/tools/add-policies.d.ts +24 -0
- package/tools/add-policies.js +97 -0
- package/tools/add-records.d.ts +18 -0
- package/tools/add-records.js +125 -0
- package/tools/add-relation-schema.d.ts +29 -0
- package/tools/add-relation-schema.js +60 -0
- package/tools/add-relation.d.ts +10 -0
- package/tools/add-relation.js +94 -0
- package/tools/create-api-key.d.ts +25 -0
- package/tools/create-api-key.js +220 -0
- package/tools/describe-schema.d.ts +19 -0
- package/tools/describe-schema.js +133 -0
- package/tools/manage-entity-types.d.ts +40 -0
- package/tools/manage-entity-types.js +206 -0
- package/tools/mutations.d.ts +35 -0
- package/tools/mutations.js +233 -0
- package/tools/provision-blueprint.d.ts +21 -0
- package/tools/provision-blueprint.js +112 -0
- package/tools/provision-database.d.ts +18 -0
- package/tools/provision-database.js +266 -0
- package/tools/run-codegen.d.ts +12 -0
- package/tools/run-codegen.js +141 -0
- package/tools/templates.d.ts +71 -0
- package/tools/templates.js +335 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { buildNodeData, getPolicyFieldDefaults, getPolicyProvisioningConfig } from '@agentic-kit/harness';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { resolveProjectContext } from '../context';
|
|
4
|
+
import { addPoliciesToExistingTable } from '../policy/add-policies-to-table';
|
|
5
|
+
import { resolveSchema, resolveTable } from '../schema-resolve';
|
|
6
|
+
const DEFAULT_ROLE = 'authenticated';
|
|
7
|
+
const CrudOperationSchema = z.enum(['create', 'read', 'update', 'delete']);
|
|
8
|
+
const PolicyEntrySchema = z.object({
|
|
9
|
+
policy_type: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe('Policy type id. Supported: AuthzAllowAll, AuthzDenyAll, AuthzAppMembership, AuthzDirectOwner, AuthzEntityMembership, AuthzOrgHierarchy, AuthzPublishable (these auto-create columns via their Data* node), AuthzRelatedMemberList, AuthzRelatedEntityMembership. Not supported (use the policies UI): AuthzDirectOwnerAny, AuthzMemberList, AuthzTemporal, AuthzComposite.'),
|
|
12
|
+
operations: z
|
|
13
|
+
.array(CrudOperationSchema)
|
|
14
|
+
.min(1)
|
|
15
|
+
.describe('CRUD operations the policy applies to: any subset of ["create","read","update","delete"].'),
|
|
16
|
+
role_name: z
|
|
17
|
+
.string()
|
|
18
|
+
.describe('PostgreSQL role the policy applies to. Defaults to "authenticated".')
|
|
19
|
+
.optional(),
|
|
20
|
+
is_permissive: z
|
|
21
|
+
.boolean()
|
|
22
|
+
.describe('PERMISSIVE (default true) vs RESTRICTIVE — most policies should be permissive.')
|
|
23
|
+
.optional(),
|
|
24
|
+
data: z
|
|
25
|
+
.record(z.string(), z.unknown())
|
|
26
|
+
.describe('Type-specific shared data. AuthzDirectOwner: { entity_field: "owner_id" }. AuthzAppMembership: { permission?, is_admin?, is_owner? }. Empty object {} for AuthzAllowAll / AuthzDenyAll.')
|
|
27
|
+
.optional(),
|
|
28
|
+
node_data: z
|
|
29
|
+
.record(z.string(), z.unknown())
|
|
30
|
+
.describe('Optional data for the underlying Data* node (has-module policies only).')
|
|
31
|
+
.optional(),
|
|
32
|
+
});
|
|
33
|
+
const AddPoliciesZod = z.object({
|
|
34
|
+
table_name: z
|
|
35
|
+
.string()
|
|
36
|
+
.describe('Name of the existing table to attach the policies to. Use describe_schema first to verify it exists.'),
|
|
37
|
+
policies: z
|
|
38
|
+
.array(PolicyEntrySchema)
|
|
39
|
+
.min(1)
|
|
40
|
+
.describe('One or more policies to add. Each entry becomes one secure_table_provision call.'),
|
|
41
|
+
});
|
|
42
|
+
function buildOperations(roleName, isPermissive) {
|
|
43
|
+
const config = { roleName, isPermissive, policyData: {}, isCustomized: false };
|
|
44
|
+
return { create: config, read: config, update: config, delete: config };
|
|
45
|
+
}
|
|
46
|
+
export const addPoliciesTool = {
|
|
47
|
+
name: 'add_policies',
|
|
48
|
+
label: 'Add policies',
|
|
49
|
+
description: 'Add one or more RLS policies to an existing table. Has-module policies (AuthzDirectOwner, AuthzEntityMembership, AuthzOrgHierarchy, AuthzPublishable) auto-create the columns they need — call directly, do not add columns first.',
|
|
50
|
+
promptSnippet: 'add_policies: attach RLS policies to an existing table (one call can add many). Gated.',
|
|
51
|
+
parameters: AddPoliciesZod,
|
|
52
|
+
async execute(params, ctx) {
|
|
53
|
+
const resolved = await resolveProjectContext(ctx.cwd);
|
|
54
|
+
if (!resolved.context) {
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: 'text', text: resolved.reason }],
|
|
57
|
+
details: { success: false, message: resolved.reason },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const { modules, databaseId, schemaId } = resolved.context;
|
|
61
|
+
try {
|
|
62
|
+
const schema = await resolveSchema(resolved.context);
|
|
63
|
+
const table = resolveTable(schema, params.table_name);
|
|
64
|
+
const policies = params.policies.map((entry) => {
|
|
65
|
+
const config = getPolicyProvisioningConfig(entry.policy_type);
|
|
66
|
+
const defaults = getPolicyFieldDefaults(entry.policy_type);
|
|
67
|
+
const mergedData = { ...defaults, ...(entry.data ?? {}) };
|
|
68
|
+
const nodeData = entry.node_data ?? buildNodeData(entry.policy_type, mergedData);
|
|
69
|
+
return {
|
|
70
|
+
policyType: entry.policy_type,
|
|
71
|
+
dataNodeType: config?.dataNodeType,
|
|
72
|
+
nodeData,
|
|
73
|
+
sharedPolicyData: mergedData,
|
|
74
|
+
operations: buildOperations(entry.role_name ?? DEFAULT_ROLE, entry.is_permissive ?? true),
|
|
75
|
+
enabledOperations: entry.operations,
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
await addPoliciesToExistingTable({
|
|
79
|
+
client: modules,
|
|
80
|
+
databaseId,
|
|
81
|
+
schemaId,
|
|
82
|
+
tableId: table.id,
|
|
83
|
+
policies,
|
|
84
|
+
});
|
|
85
|
+
const count = params.policies.length;
|
|
86
|
+
const message = `Added ${count} ${count === 1 ? 'policy' : 'policies'} to ${params.table_name}`;
|
|
87
|
+
return { content: [{ type: 'text', text: message }], details: { success: true, message } };
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const message = err instanceof Error ? err.message : 'Failed to add policies';
|
|
91
|
+
return { content: [{ type: 'text', text: message }], details: { success: false, message } };
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HarnessTool } from '@agentic-kit/harness';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export type AddRecordsDetails = {
|
|
4
|
+
success: boolean;
|
|
5
|
+
message: string;
|
|
6
|
+
tableName?: string;
|
|
7
|
+
columns?: string[];
|
|
8
|
+
rows?: Record<string, unknown>[];
|
|
9
|
+
createdCount?: number;
|
|
10
|
+
asOf?: string;
|
|
11
|
+
needsAuth?: boolean;
|
|
12
|
+
};
|
|
13
|
+
declare const AddRecordsZod: z.ZodObject<{
|
|
14
|
+
table_name: z.ZodString;
|
|
15
|
+
records: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
16
|
+
}, z.core.$strip>;
|
|
17
|
+
export declare const addRecordsTool: HarnessTool<typeof AddRecordsZod, AddRecordsDetails>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { buildPostGraphileCreate, toCamelCaseSingular, toCreateMutationName, } from '@constructive-io/graphql-query/generators';
|
|
2
|
+
import { objects } from '@constructive-io/sdk';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { resolveDataToken, resolveProjectContext } from '../context';
|
|
5
|
+
import { cleanTable, findMetaTable, META_QUERY } from '../records/meta';
|
|
6
|
+
function fail(message) {
|
|
7
|
+
return { content: [{ type: 'text', text: message }], details: { success: false, message } };
|
|
8
|
+
}
|
|
9
|
+
function needsAuth(message) {
|
|
10
|
+
return {
|
|
11
|
+
content: [{ type: 'text', text: message }],
|
|
12
|
+
details: { success: false, message, needsAuth: true },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
// Mirror @constructive-io/data's prepareCreateInput (create semantics): drop
|
|
16
|
+
// undefined/null/empty-string so server defaults apply; keep false/0/[]/objects.
|
|
17
|
+
function stripForCreate(record) {
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const [key, value] of Object.entries(record)) {
|
|
20
|
+
if (value === undefined || value === null || value === '')
|
|
21
|
+
continue;
|
|
22
|
+
out[key] = value;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
const AddRecordsZod = z.object({
|
|
27
|
+
table_name: z
|
|
28
|
+
.string()
|
|
29
|
+
.describe('Name of the table to insert rows into (snake_case, singular or plural).'),
|
|
30
|
+
records: z
|
|
31
|
+
.array(z.record(z.string(), z.unknown()))
|
|
32
|
+
.describe('Rows to insert. Each is an object of column → value (camelCase or snake_case column names).'),
|
|
33
|
+
});
|
|
34
|
+
export const addRecordsTool = {
|
|
35
|
+
name: 'add_records',
|
|
36
|
+
label: 'Add records',
|
|
37
|
+
description: "Insert one or more rows into a table in the project's app database.",
|
|
38
|
+
promptSnippet: 'add_records: insert rows into an existing table. Gated.',
|
|
39
|
+
parameters: AddRecordsZod,
|
|
40
|
+
async execute(params, ctx) {
|
|
41
|
+
const resolved = await resolveProjectContext(ctx.cwd);
|
|
42
|
+
if (!resolved.context)
|
|
43
|
+
return fail(resolved.reason);
|
|
44
|
+
const { dataEndpoint } = resolved.context;
|
|
45
|
+
if (!dataEndpoint) {
|
|
46
|
+
return fail('Cannot resolve the app data endpoint (DATABASE_NAME missing from .env). Re-provision the database, then retry.');
|
|
47
|
+
}
|
|
48
|
+
if (params.records.length === 0)
|
|
49
|
+
return fail('No records provided.');
|
|
50
|
+
const token = await resolveDataToken(resolved.context);
|
|
51
|
+
if (!token.token)
|
|
52
|
+
return needsAuth(token.reason ?? 'Sign in to the database to insert records.');
|
|
53
|
+
const adapter = new objects.FetchAdapter(dataEndpoint, {
|
|
54
|
+
Authorization: `Bearer ${token.token}`,
|
|
55
|
+
});
|
|
56
|
+
let tables;
|
|
57
|
+
try {
|
|
58
|
+
const meta = await adapter.execute(META_QUERY);
|
|
59
|
+
if (!meta.ok) {
|
|
60
|
+
return fail(`Failed to read schema from the app database: ${meta.errors.map((e) => e.message).join('; ')}`);
|
|
61
|
+
}
|
|
62
|
+
tables = (meta.data._meta?.tables ?? []).filter((t) => Boolean(t));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return fail(err instanceof Error ? err.message : 'Failed to query the app database schema.');
|
|
66
|
+
}
|
|
67
|
+
const rawTable = findMetaTable(tables, params.table_name);
|
|
68
|
+
if (!rawTable) {
|
|
69
|
+
return fail(`Table "${params.table_name}" was not found in the app database.`);
|
|
70
|
+
}
|
|
71
|
+
const table = cleanTable(rawTable);
|
|
72
|
+
const allTables = tables.map(cleanTable);
|
|
73
|
+
const mutation = buildPostGraphileCreate(table, allTables).toString();
|
|
74
|
+
const mutationName = toCreateMutationName(table.name, table);
|
|
75
|
+
const singularName = toCamelCaseSingular(table.name, table);
|
|
76
|
+
// Constructive app tables scope rows by entityId (the owning user's id). The
|
|
77
|
+
// create input requires it, but callers won't know it — backfill it with the
|
|
78
|
+
// signed-in user's id from the Preview session when the table has the column
|
|
79
|
+
// and the record omits it.
|
|
80
|
+
const hasEntityId = table.fields.some((f) => f.name === 'entityId');
|
|
81
|
+
const created = [];
|
|
82
|
+
const errors = [];
|
|
83
|
+
for (const record of params.records) {
|
|
84
|
+
const prepared = hasEntityId && token.userId && record.entityId == null && record.entity_id == null
|
|
85
|
+
? { ...record, entityId: token.userId }
|
|
86
|
+
: record;
|
|
87
|
+
const variables = { input: { [singularName]: stripForCreate(prepared) } };
|
|
88
|
+
try {
|
|
89
|
+
const result = await adapter.execute(mutation, variables);
|
|
90
|
+
if (!result.ok) {
|
|
91
|
+
errors.push(result.errors.map((e) => e.message).join('; '));
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const payload = result.data[mutationName];
|
|
95
|
+
const row = payload?.[singularName] ?? {};
|
|
96
|
+
created.push(row);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
errors.push(err instanceof Error ? err.message : 'Unknown error');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (created.length === 0) {
|
|
103
|
+
return fail(`Failed to insert into "${params.table_name}": ${errors.join('; ') || 'unknown error'}`);
|
|
104
|
+
}
|
|
105
|
+
const columns = table.fields.map((f) => f.name);
|
|
106
|
+
const message = errors.length > 0
|
|
107
|
+
? `Inserted ${created.length} of ${params.records.length} row(s) into ${params.table_name} (${errors.length} failed).`
|
|
108
|
+
: `Inserted ${created.length} row(s) into ${params.table_name}.`;
|
|
109
|
+
return {
|
|
110
|
+
content: [{ type: 'text', text: message }],
|
|
111
|
+
details: {
|
|
112
|
+
success: true,
|
|
113
|
+
message,
|
|
114
|
+
tableName: rawTable.name,
|
|
115
|
+
columns,
|
|
116
|
+
rows: created,
|
|
117
|
+
createdCount: created.length,
|
|
118
|
+
asOf: new Date().toISOString(),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BlueprintDefinition } from '@agentic-kit/harness';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export declare const DeleteAction: z.ZodEnum<{
|
|
4
|
+
c: "c";
|
|
5
|
+
r: "r";
|
|
6
|
+
n: "n";
|
|
7
|
+
a: "a";
|
|
8
|
+
d: "d";
|
|
9
|
+
}>;
|
|
10
|
+
export declare const AddRelationZod: z.ZodObject<{
|
|
11
|
+
relation_type: z.ZodOptional<z.ZodEnum<{
|
|
12
|
+
belongs_to: "belongs_to";
|
|
13
|
+
many_to_many: "many_to_many";
|
|
14
|
+
}>>;
|
|
15
|
+
source_table: z.ZodString;
|
|
16
|
+
target_table: z.ZodString;
|
|
17
|
+
field_name: z.ZodOptional<z.ZodString>;
|
|
18
|
+
junction_table_name: z.ZodOptional<z.ZodString>;
|
|
19
|
+
delete_action: z.ZodOptional<z.ZodEnum<{
|
|
20
|
+
c: "c";
|
|
21
|
+
r: "r";
|
|
22
|
+
n: "n";
|
|
23
|
+
a: "a";
|
|
24
|
+
d: "d";
|
|
25
|
+
}>>;
|
|
26
|
+
is_required: z.ZodOptional<z.ZodBoolean>;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
export type AddRelationParams = z.infer<typeof AddRelationZod>;
|
|
29
|
+
export declare function buildRelation(params: AddRelationParams): BlueprintDefinition['relations'];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// delete_action is intentionally always sent: the backend REJECTS a
|
|
3
|
+
// RelationBelongsTo whose delete_action is absent ("PROVISION_RELATION:
|
|
4
|
+
// delete_action is required"). We default it to 'a' (NO ACTION) so the model
|
|
5
|
+
// never has to supply it.
|
|
6
|
+
export const DeleteAction = z
|
|
7
|
+
.enum(['c', 'r', 'n', 'a', 'd'])
|
|
8
|
+
.describe('FK delete action: c=CASCADE, r=RESTRICT, n=SET NULL, a=NO ACTION (default), d=SET DEFAULT');
|
|
9
|
+
export const AddRelationZod = z.object({
|
|
10
|
+
relation_type: z
|
|
11
|
+
.enum(['belongs_to', 'many_to_many'])
|
|
12
|
+
.describe('belongs_to adds an FK column on the source table; many_to_many adds a junction table. Defaults to belongs_to.')
|
|
13
|
+
.optional(),
|
|
14
|
+
source_table: z.string().describe('Existing source table name (snake_case).'),
|
|
15
|
+
target_table: z.string().describe('Existing target table name (snake_case).'),
|
|
16
|
+
field_name: z
|
|
17
|
+
.string()
|
|
18
|
+
.describe('belongs_to only: FK column name to add on the source table, e.g. "author_id".')
|
|
19
|
+
.optional(),
|
|
20
|
+
junction_table_name: z
|
|
21
|
+
.string()
|
|
22
|
+
.describe('many_to_many only: name of the junction table to create, e.g. "post_tags".')
|
|
23
|
+
.optional(),
|
|
24
|
+
delete_action: DeleteAction.optional(),
|
|
25
|
+
is_required: z
|
|
26
|
+
.boolean()
|
|
27
|
+
.describe('belongs_to only: make the FK NOT NULL. Defaults to false — a NOT NULL FK fails if the source table already has rows.')
|
|
28
|
+
.optional(),
|
|
29
|
+
});
|
|
30
|
+
// Build a relation-only blueprint definition (no tables): the spike proved
|
|
31
|
+
// constructBlueprint applies relations to PRE-EXISTING tables without recreating
|
|
32
|
+
// them, as long as delete_action is supplied.
|
|
33
|
+
export function buildRelation(params) {
|
|
34
|
+
const deleteAction = params.delete_action ?? 'a';
|
|
35
|
+
if ((params.relation_type ?? 'belongs_to') === 'many_to_many') {
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
$type: 'RelationManyToMany',
|
|
39
|
+
source_table: params.source_table,
|
|
40
|
+
target_table: params.target_table,
|
|
41
|
+
junction_table_name: params.junction_table_name,
|
|
42
|
+
delete_action: deleteAction,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
$type: 'RelationBelongsTo',
|
|
49
|
+
source_table: params.source_table,
|
|
50
|
+
target_table: params.target_table,
|
|
51
|
+
field_name: params.field_name,
|
|
52
|
+
delete_action: deleteAction,
|
|
53
|
+
is_required: params.is_required ?? false,
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type HarnessTool } from '@agentic-kit/harness';
|
|
2
|
+
import { AddRelationZod } from './add-relation-schema';
|
|
3
|
+
export type AddRelationDetails = {
|
|
4
|
+
success: boolean;
|
|
5
|
+
message: string;
|
|
6
|
+
sourceTable?: string;
|
|
7
|
+
targetTable?: string;
|
|
8
|
+
fieldName?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare const addRelationTool: HarnessTool<typeof AddRelationZod, AddRelationDetails>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { expandBlueprintDefaults } from '@agentic-kit/harness';
|
|
2
|
+
import { resolveProjectContext } from '../context';
|
|
3
|
+
import { resolveSchema, resolveTable } from '../schema-resolve';
|
|
4
|
+
import { AddRelationZod, buildRelation } from './add-relation-schema';
|
|
5
|
+
function fail(message) {
|
|
6
|
+
return { content: [{ type: 'text', text: message }], details: { success: false, message } };
|
|
7
|
+
}
|
|
8
|
+
export const addRelationTool = {
|
|
9
|
+
name: 'add_relation',
|
|
10
|
+
label: 'Add relation',
|
|
11
|
+
description: 'Add a relation between two EXISTING tables without recreating them: belongs_to adds a foreign-key column on the source table; many_to_many creates a junction table. Use this for relations discovered after the tables were provisioned. For brand-new related tables, prefer a single provision_blueprint call.',
|
|
12
|
+
promptSnippet: 'add_relation: link two existing tables (belongs_to FK column, or many_to_many junction). Gated.',
|
|
13
|
+
parameters: AddRelationZod,
|
|
14
|
+
async execute(params, ctx) {
|
|
15
|
+
const type = params.relation_type ?? 'belongs_to';
|
|
16
|
+
if (type === 'belongs_to' && !params.field_name) {
|
|
17
|
+
return fail('field_name is required for a belongs_to relation (the FK column name on the source table).');
|
|
18
|
+
}
|
|
19
|
+
if (type === 'many_to_many' && !params.junction_table_name) {
|
|
20
|
+
return fail('junction_table_name is required for a many_to_many relation.');
|
|
21
|
+
}
|
|
22
|
+
const resolved = await resolveProjectContext(ctx.cwd);
|
|
23
|
+
if (!resolved.context)
|
|
24
|
+
return fail(resolved.reason);
|
|
25
|
+
const { modules, databaseId, schemaId, ownerId } = resolved.context;
|
|
26
|
+
if (!ownerId)
|
|
27
|
+
return fail('Not authenticated (missing OWNER_ID in .env).');
|
|
28
|
+
// Validate both tables exist before constructing — a clearer error than the
|
|
29
|
+
// backend's, and avoids a no-op blueprint.
|
|
30
|
+
let schema;
|
|
31
|
+
try {
|
|
32
|
+
schema = await resolveSchema(resolved.context);
|
|
33
|
+
resolveTable(schema, params.source_table);
|
|
34
|
+
resolveTable(schema, params.target_table);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
return fail(err instanceof Error ? err.message : 'Failed to resolve schema.');
|
|
38
|
+
}
|
|
39
|
+
const definition = expandBlueprintDefaults({ tables: [], relations: buildRelation(params) });
|
|
40
|
+
const uniqueName = `add_relation_${Date.now()}`;
|
|
41
|
+
try {
|
|
42
|
+
const created = await modules.blueprint
|
|
43
|
+
.create({
|
|
44
|
+
data: {
|
|
45
|
+
ownerId,
|
|
46
|
+
databaseId,
|
|
47
|
+
name: uniqueName,
|
|
48
|
+
displayName: `${params.source_table} → ${params.target_table}`,
|
|
49
|
+
description: `Add ${type} relation`,
|
|
50
|
+
definition,
|
|
51
|
+
},
|
|
52
|
+
select: { id: true },
|
|
53
|
+
})
|
|
54
|
+
.unwrap();
|
|
55
|
+
const blueprintId = created.createBlueprint?.blueprint?.id;
|
|
56
|
+
if (!blueprintId)
|
|
57
|
+
throw new Error('Failed to create relation blueprint');
|
|
58
|
+
const constructResult = await modules.mutation
|
|
59
|
+
.constructBlueprint({ input: { blueprintId, schemaId } }, { select: { result: true } })
|
|
60
|
+
.unwrap();
|
|
61
|
+
if (!constructResult.constructBlueprint?.result) {
|
|
62
|
+
const constructions = await modules.blueprintConstruction
|
|
63
|
+
.findMany({
|
|
64
|
+
where: { blueprintId: { equalTo: blueprintId } },
|
|
65
|
+
orderBy: ['ID_DESC'],
|
|
66
|
+
first: 1,
|
|
67
|
+
select: { status: true, errorDetails: true },
|
|
68
|
+
})
|
|
69
|
+
.unwrap();
|
|
70
|
+
const error = constructions?.blueprintConstructions?.nodes?.[0]?.errorDetails ?? 'Unknown error';
|
|
71
|
+
return fail(`Failed to add relation: ${typeof error === 'string' ? error : JSON.stringify(error)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
return fail(`Failed to add relation: ${err instanceof Error ? err.message : String(err)}`);
|
|
76
|
+
}
|
|
77
|
+
const message = type === 'many_to_many'
|
|
78
|
+
? `Created junction table "${params.junction_table_name}" linking ${params.source_table} ↔ ${params.target_table}.`
|
|
79
|
+
: `Added FK "${params.field_name}" on ${params.source_table} → ${params.target_table}.`;
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: 'text', text: message }],
|
|
82
|
+
details: {
|
|
83
|
+
success: true,
|
|
84
|
+
message,
|
|
85
|
+
sourceTable: params.source_table,
|
|
86
|
+
targetTable: params.target_table,
|
|
87
|
+
fieldName: params.field_name,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { HarnessTool } from '@agentic-kit/harness';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
declare const CreateApiKeyZod: z.ZodObject<{
|
|
4
|
+
key_name: z.ZodString;
|
|
5
|
+
principal_name: z.ZodOptional<z.ZodString>;
|
|
6
|
+
entity_ids: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
7
|
+
read_only: z.ZodOptional<z.ZodBoolean>;
|
|
8
|
+
expires_in_days: z.ZodOptional<z.ZodNumber>;
|
|
9
|
+
}, z.core.$strip>;
|
|
10
|
+
export type CreateApiKeyDetails = {
|
|
11
|
+
success: boolean;
|
|
12
|
+
message: string;
|
|
13
|
+
keyId?: string;
|
|
14
|
+
envVar?: string;
|
|
15
|
+
expiresAt?: string;
|
|
16
|
+
principalId?: string;
|
|
17
|
+
principalName?: string;
|
|
18
|
+
scope?: string;
|
|
19
|
+
needsAuth?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export declare function toEnvVar(keyName: string): string;
|
|
22
|
+
export declare function isStepUpError(messages: string[]): boolean;
|
|
23
|
+
export declare function describeScope(entityIds: string[] | undefined, readOnly: boolean): string;
|
|
24
|
+
export declare const createApiKeyTool: HarnessTool<typeof CreateApiKeyZod, CreateApiKeyDetails>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { auth } from '@constructive-io/sdk';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { deriveSubdomainEndpoint, resolveDataToken, resolveProjectContext } from '../context';
|
|
4
|
+
import { getHost } from '../host';
|
|
5
|
+
const CreateApiKeyZod = z.object({
|
|
6
|
+
key_name: z
|
|
7
|
+
.string()
|
|
8
|
+
.describe('Name for the API key (e.g. "deploy bot"). The .env variable name derives from it (UPPER_SNAKE + _API_KEY).'),
|
|
9
|
+
principal_name: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe('Machine identity (principal) that owns the key. Defaults to key_name. An existing principal with this name is reused for unscoped keys.')
|
|
12
|
+
.optional(),
|
|
13
|
+
entity_ids: z
|
|
14
|
+
.array(z.string())
|
|
15
|
+
.describe('Entity row UUIDs to scope the principal to. Omit for a personal (unscoped) key that acts as the signed-in app user.')
|
|
16
|
+
.optional(),
|
|
17
|
+
read_only: z
|
|
18
|
+
.boolean()
|
|
19
|
+
.describe('Restrict the principal and key to read-only operations.')
|
|
20
|
+
.optional(),
|
|
21
|
+
expires_in_days: z
|
|
22
|
+
.number()
|
|
23
|
+
.int()
|
|
24
|
+
.positive()
|
|
25
|
+
.describe('Key lifetime in days. Default 90.')
|
|
26
|
+
.optional(),
|
|
27
|
+
});
|
|
28
|
+
function fail(message, extra) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: 'text', text: message }],
|
|
31
|
+
details: { success: false, message, ...extra },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function toEnvVar(keyName) {
|
|
35
|
+
const base = keyName
|
|
36
|
+
.trim()
|
|
37
|
+
.toUpperCase()
|
|
38
|
+
.replace(/[^A-Z0-9]+/g, '_')
|
|
39
|
+
.replace(/^_+|_+$/g, '');
|
|
40
|
+
return base.endsWith('_API_KEY') ? base : `${base}_API_KEY`;
|
|
41
|
+
}
|
|
42
|
+
export function isStepUpError(messages) {
|
|
43
|
+
return messages.some((m) => /step[\s_-]?up/i.test(m));
|
|
44
|
+
}
|
|
45
|
+
export function describeScope(entityIds, readOnly) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
parts.push(entityIds?.length
|
|
48
|
+
? `scoped to ${entityIds.length} entit${entityIds.length === 1 ? 'y' : 'ies'}`
|
|
49
|
+
: 'unscoped (acts as the signed-in app user)');
|
|
50
|
+
if (readOnly)
|
|
51
|
+
parts.push('read-only');
|
|
52
|
+
return parts.join(', ');
|
|
53
|
+
}
|
|
54
|
+
async function rawGraphql(endpoint, token, query, variables) {
|
|
55
|
+
const res = await fetch(endpoint, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: {
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({ query, variables }),
|
|
62
|
+
});
|
|
63
|
+
return (await res.json());
|
|
64
|
+
}
|
|
65
|
+
async function createPrincipalInputFields(endpoint) {
|
|
66
|
+
const probe = await rawGraphql(endpoint, undefined, '{ __type(name: "CreatePrincipalInput") { inputFields { name } } }');
|
|
67
|
+
const type = probe.data?.__type;
|
|
68
|
+
return new Set((type?.inputFields ?? []).map((f) => f.name));
|
|
69
|
+
}
|
|
70
|
+
export const createApiKeyTool = {
|
|
71
|
+
name: 'create_api_key',
|
|
72
|
+
label: 'Create API key',
|
|
73
|
+
description: 'Mint an API key for the project database, owned by a machine principal. Optionally scope it to specific entity rows and/or read-only. The human confirms the mint, completes MFA step-up in the app when required, and receives the key via .env + a one-time reveal — the plaintext key never appears in this conversation. Requires the user to be signed in to their app in the Preview.',
|
|
74
|
+
promptSnippet: 'create_api_key: mint an entity-scoped API key for the project database. Gated; the plaintext goes to .env + a one-time reveal, never into the chat — reference it as its env var.',
|
|
75
|
+
parameters: CreateApiKeyZod,
|
|
76
|
+
async execute(params, ctx) {
|
|
77
|
+
const keyName = params.key_name?.trim();
|
|
78
|
+
if (!keyName)
|
|
79
|
+
return fail('create_api_key requires "key_name".');
|
|
80
|
+
const resolved = await resolveProjectContext(ctx.cwd, { plane: 'data' });
|
|
81
|
+
if (!resolved.context)
|
|
82
|
+
return fail(resolved.reason);
|
|
83
|
+
const { databaseId, databaseName, apiEndpoint } = resolved.context;
|
|
84
|
+
if (!databaseName) {
|
|
85
|
+
return fail('Cannot resolve the app auth endpoint (DATABASE_NAME missing from .env). Re-provision the database, then retry.');
|
|
86
|
+
}
|
|
87
|
+
const host = getHost();
|
|
88
|
+
if (!host.deliverSecret) {
|
|
89
|
+
return fail('This host cannot deliver secrets (no .env write + reveal flow), so no key was minted. Create the key from a host that supports secret delivery.');
|
|
90
|
+
}
|
|
91
|
+
const token = await resolveDataToken(resolved.context);
|
|
92
|
+
if (!token.token) {
|
|
93
|
+
return fail(token.reason ?? 'Sign in to the app database to create an API key.', {
|
|
94
|
+
needsAuth: true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const authEndpoint = deriveSubdomainEndpoint(apiEndpoint, `auth-${databaseName}`);
|
|
98
|
+
if (!authEndpoint)
|
|
99
|
+
return fail('Cannot derive the app auth endpoint.');
|
|
100
|
+
const scoped = Boolean(params.entity_ids?.length) || params.read_only === true;
|
|
101
|
+
try {
|
|
102
|
+
if (scoped) {
|
|
103
|
+
const fields = await createPrincipalInputFields(authEndpoint);
|
|
104
|
+
const missing = [
|
|
105
|
+
...(params.entity_ids?.length && !fields.has('entityIds') ? ['entityIds'] : []),
|
|
106
|
+
...(params.read_only === true && !fields.has('isReadOnly') ? ['isReadOnly'] : []),
|
|
107
|
+
];
|
|
108
|
+
if (missing.length) {
|
|
109
|
+
return fail(`This deployment does not support scoping a principal at create time (no ${missing.join(', ')} on createPrincipal). No key was minted. If an unscoped key that acts as the signed-in user is acceptable, ask for one explicitly.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const dbAuth = auth.createClient({
|
|
113
|
+
endpoint: authEndpoint,
|
|
114
|
+
headers: { Authorization: `Bearer ${token.token}` },
|
|
115
|
+
});
|
|
116
|
+
const principalName = params.principal_name?.trim() || keyName;
|
|
117
|
+
const existing = await dbAuth.principal
|
|
118
|
+
.findFirst({
|
|
119
|
+
where: { name: { equalTo: principalName } },
|
|
120
|
+
select: { id: true, name: true, userId: true, isReadOnly: true },
|
|
121
|
+
})
|
|
122
|
+
.unwrap();
|
|
123
|
+
// createApiKey (like createPrincipal.result) takes the principal's
|
|
124
|
+
// identity userId; principal_entity.principalId references the row id.
|
|
125
|
+
let principalId = existing.principal?.userId;
|
|
126
|
+
if (existing.principal && scoped) {
|
|
127
|
+
return fail(`Principal "${principalName}" already exists, and its scope cannot be verified against the requested one. No key was minted. Use a new principal_name for a scoped key, or mint an unscoped key on the existing principal explicitly.`);
|
|
128
|
+
}
|
|
129
|
+
if (existing.principal) {
|
|
130
|
+
const scopeRow = await dbAuth.principalEntity
|
|
131
|
+
.findFirst({
|
|
132
|
+
where: { principalId: { equalTo: existing.principal.id } },
|
|
133
|
+
select: { id: true },
|
|
134
|
+
})
|
|
135
|
+
.unwrap();
|
|
136
|
+
if (scopeRow.principalEntity || existing.principal?.isReadOnly) {
|
|
137
|
+
return fail(`Principal "${principalName}" already exists with a narrower scope (entity-scoped or read-only), so a key minted under it would not act as the signed-in user. No key was minted. Use a new principal_name for an unscoped key.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!principalId) {
|
|
141
|
+
// The SDK's PrincipalModel.create sends a nested {principal} input, but
|
|
142
|
+
// the live CreatePrincipalInput is flat and its payload only exposes
|
|
143
|
+
// `result: UUID` — raw GraphQL is the only working surface.
|
|
144
|
+
const created = await rawGraphql(authEndpoint, token.token, 'mutation ($input: CreatePrincipalInput!) { createPrincipal(input: $input) { result } }', {
|
|
145
|
+
input: {
|
|
146
|
+
name: principalName,
|
|
147
|
+
...(params.entity_ids?.length && { entityIds: params.entity_ids }),
|
|
148
|
+
...(params.read_only !== undefined && { isReadOnly: params.read_only }),
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
if (created.errors?.length) {
|
|
152
|
+
return fail(`createPrincipal failed: ${created.errors.map((e) => e.message).join('; ')}`);
|
|
153
|
+
}
|
|
154
|
+
const payload = created.data?.createPrincipal;
|
|
155
|
+
principalId = payload?.result;
|
|
156
|
+
if (!principalId)
|
|
157
|
+
return fail('createPrincipal returned no principal id.');
|
|
158
|
+
}
|
|
159
|
+
const mint = () => dbAuth.mutation
|
|
160
|
+
.createApiKey({
|
|
161
|
+
input: {
|
|
162
|
+
principalId: principalId,
|
|
163
|
+
keyName,
|
|
164
|
+
...(params.read_only && { accessLevel: 'read_only' }),
|
|
165
|
+
expiresIn: { days: params.expires_in_days ?? 90 },
|
|
166
|
+
},
|
|
167
|
+
}, { select: { result: { select: { apiKey: true, keyId: true, expiresAt: true } } } })
|
|
168
|
+
.execute();
|
|
169
|
+
let minted = await mint();
|
|
170
|
+
if (!minted.ok && isStepUpError(minted.errors.map((e) => e.message))) {
|
|
171
|
+
if (!host.requestStepUp) {
|
|
172
|
+
return fail('Creating this key requires MFA step-up, and this host has no step-up flow. Complete step-up in the app (verify your password), then retry.');
|
|
173
|
+
}
|
|
174
|
+
const verified = await host.requestStepUp({ databaseId, databaseName, apiEndpoint });
|
|
175
|
+
if (!verified)
|
|
176
|
+
return fail('Step-up verification was not completed. No key was minted.');
|
|
177
|
+
minted = await mint();
|
|
178
|
+
}
|
|
179
|
+
if (!minted.ok) {
|
|
180
|
+
return fail(`createApiKey failed: ${minted.errors.map((e) => e.message).join('; ')}`);
|
|
181
|
+
}
|
|
182
|
+
const record = minted.data?.createApiKey?.result;
|
|
183
|
+
if (!record?.apiKey || !record.keyId)
|
|
184
|
+
return fail('createApiKey returned no key.');
|
|
185
|
+
const envVar = toEnvVar(keyName);
|
|
186
|
+
await host.deliverSecret({
|
|
187
|
+
databaseId,
|
|
188
|
+
cwd: ctx.cwd,
|
|
189
|
+
envVar,
|
|
190
|
+
plaintext: record.apiKey,
|
|
191
|
+
keyId: record.keyId,
|
|
192
|
+
expiresAt: record.expiresAt ?? undefined,
|
|
193
|
+
});
|
|
194
|
+
const scope = describeScope(params.entity_ids, params.read_only === true);
|
|
195
|
+
const message = `Created API key "${keyName}" (keyId: ${record.keyId}) for principal "${principalName}" — ${scope}. The key was written to .env as ${envVar} and revealed once to the user; it is not in this conversation. Reference it as ${envVar}.${record.expiresAt ? ` Expires ${record.expiresAt}.` : ''}`;
|
|
196
|
+
return {
|
|
197
|
+
content: [{ type: 'text', text: message }],
|
|
198
|
+
details: {
|
|
199
|
+
success: true,
|
|
200
|
+
message,
|
|
201
|
+
keyId: record.keyId,
|
|
202
|
+
envVar,
|
|
203
|
+
expiresAt: record.expiresAt ?? undefined,
|
|
204
|
+
principalId,
|
|
205
|
+
principalName,
|
|
206
|
+
scope,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
return fail(err instanceof Error ? err.message : 'Failed to create the API key.');
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
};
|