@dypai-ai/mcp 1.5.6 → 1.5.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dypai-ai/mcp",
3
- "version": "1.5.6",
3
+ "version": "1.5.8",
4
4
  "description": "DYPAI MCP Server — AI agent toolkit for building and deploying full-stack apps",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -115,7 +115,37 @@ const REMOTE_TOOLS = [
115
115
  // ── Project ───────────────────────────────────────────────────────────────
116
116
  { name: "list_projects", description: "Lists all projects you have access to across your organizations. Returns project id, name, description, organization, subscription plan, and status. Use this as the first step to discover which projects are available, then pass project_id to other tools.", inputSchema: { type: "object", properties: { organization_id: { type: "string", description: "Optional. Filter projects by organization UUID." } }, required: [] } },
117
117
  { name: "get_project", description: "Gets detailed information about a specific project. Returns project name, description, organization, plan, status, engine URL, frontend slug, and timestamps.", inputSchema: { type: "object", properties: { project_id: { type: "string" } }, required: ["project_id"] } },
118
- { name: "list_ai_models", description: "List only the DYPAI Managed AI models that are active for a project. Returns the project-gated OpenRouter model catalog, monthly included AI credits, monthly hard cap, RPM limit, max output tokens, active/available counts, and the exact node parameters to use. Call this before creating or editing an AI Agent node with DYPAI Managed models. Agents must not invent or use inactive model ids. Use provider='openrouter' and do NOT set credential_id; DYPAI uses the platform OpenRouter key and bills usage to the organization.", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "Project UUID whose plan and Model Gateway settings determine the active Managed AI catalog." } }, required: ["project_id"] } },
118
+ {
119
+ name: "manage_project_access_profile",
120
+ description: `Read or update the project's product access profile.
121
+
122
+ Use this when you know what kind of app is being built:
123
+ - private/admin tool: app_visibility='private', auth_scope='admin_only', has_admin_area=true, root_requires_auth=true
124
+ - public landing with admin panel: app_visibility='mixed', auth_scope='admin_only', has_public_area=true, has_admin_area=true
125
+ - customer/user portal: app_visibility='private' or 'mixed', auth_scope='end_users' or 'admin_and_end_users', has_end_user_accounts=true
126
+ - public-only site: app_visibility='public', auth_scope='none', role_model='none', root_requires_auth=false
127
+
128
+ This stores classification metadata only. It does not create users, roles, login UI, tables, endpoints, or publish anything.`,
129
+ inputSchema: {
130
+ type: "object",
131
+ properties: {
132
+ project_id: { type: "string", description: "Project UUID. Required for user tokens; auto-detected for project tokens." },
133
+ operation: { type: "string", enum: ["get", "update"], description: "Read or update the project access classification." },
134
+ app_visibility: { type: "string", enum: ["public", "private", "mixed"], description: "Public surface of the app." },
135
+ auth_scope: { type: "string", enum: ["none", "admin_only", "internal_users", "end_users", "admin_and_end_users"], description: "Who needs authentication." },
136
+ role_model: { type: "string", enum: ["none", "single_role", "multi_role"], description: "Whether the app has no roles, one role, or multiple roles." },
137
+ has_admin_area: { type: "boolean", description: "Whether the app should include an admin/private management area." },
138
+ has_public_area: { type: "boolean", description: "Whether the app should include pages usable without login." },
139
+ has_end_user_accounts: { type: "boolean", description: "Whether non-admin end users have their own accounts." },
140
+ root_requires_auth: { type: "boolean", description: "Whether the root route should require login." },
141
+ metadata_patch: { type: "object", description: "Optional non-sensitive notes to merge into access_metadata. Sensitive-looking keys are dropped." },
142
+ reason: { type: "string", description: "Short reason for the update." },
143
+ source: { type: "string", description: "Optional caller label. Defaults to mcp." },
144
+ },
145
+ required: ["operation"],
146
+ },
147
+ },
148
+ { name: "list_ai_models", description: "List only the DYPAI Managed AI models that are active for a project. Returns the project-gated OpenRouter model catalog priced in AI Credits per 1M tokens, RPM limit, max output tokens, active/available counts, billing metadata, and the exact node parameters to use. Call this before creating or editing an AI Agent node with DYPAI Managed models. Agents must not invent or use inactive model ids. Use provider='openrouter' and do NOT set credential_id; DYPAI uses the platform OpenRouter key and deducts usage from the organization's AI Credits.", inputSchema: { type: "object", properties: { project_id: { type: "string", description: "Project UUID whose plan and Model Gateway settings determine the active Managed AI catalog." } }, required: ["project_id"] } },
119
149
  { name: "create_project", description: "Create a new DYPAI project (free plan). Creates a full project with database, engine, GitHub repo, and frontend hosting. BLOCKS by default until provisioning finishes (~60s typical, 120s max) — when it returns, the project_id is ready to use with execute_sql, endpoint tools, etc. Pass wait_until_ready:false for batch flows.\n\nName collision: if another project in the same org already uses the name (case-insensitive), returns {error:'name_taken', existing_project_id, suggestions:[...]}. Pick a different name or use the existing project.\n\nIMPORTANT: before calling, check for a matching template with `search_project_templates`. Passing a `template_slug` drops in a ready-made schema + endpoints + UI that cover 70% of common app types. Only create a blank project if nothing matches.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Project name (e.g. 'My Veterinary App')" }, organization_id: { type: "string", description: "Optional. Uses default org if omitted." }, description: { type: "string" }, template_slug: { type: "string", description: "RECOMMENDED. Project template slug to start from (e.g. 'clinic', 'gym', 'waitlist', 'blank'). Always call search_project_templates first to find the best match." }, wait_until_ready: { type: "boolean", description: "If true (default), blocks until provisioning completes and the project is ready for all operations. If false, returns immediately with status='provisioning' — caller must poll get_project before using.", default: true } }, required: ["name"] } },
120
150
  { name: "get_app_credentials", description: "Lists available credentials in the current application. Returns API keys, anon key, service role key, and engine URL needed for SDK configuration.", inputSchema: { type: "object", properties: { project_id: { type: "string" } }, required: [] } },
121
151
 
@@ -354,7 +384,7 @@ Notes:
354
384
  // ── Triggers (Schedules + Webhooks) ──────────────────────────────────────
355
385
  // These tools OBSERVE + CONTROL trigger runtime state. The DEFINITION lives
356
386
  // in the endpoint YAML (`trigger.schedule` / `trigger.webhook`) — to change
357
- // a cron expression or webhook path, edit the YAML and `dypai_push`.
387
+ // a cron expression or webhook path, edit the YAML and \`dypai_push\`.
358
388
  {
359
389
  name: "manage_schedules",
360
390
  description: `Observe + control cron schedules at runtime, by endpoint name.
@@ -437,15 +467,16 @@ endpoint YAML and \`dypai_push\`. This tool does NOT modify the definition.`,
437
467
  // ── Observability ─────────────────────────────────────────────────────────
438
468
  {
439
469
  name: "search_logs",
440
- description: "Search recent errors and warnings for the current project. ALWAYS call this FIRST when the user reports any error, bug, or 'this isn't working' — don't guess from the code; check what actually broke. Returns a unified, time-ordered list mixing failed workflow executions and warn/error log lines from the engine. Defaults to the last 24h. Data retention: 7 days.\n\nWorkflow:\n 1) Call with no args (or just `since:'1h'`) → see recent failures.\n 2) Pick the relevant entry → call again with `endpoint` + tighter `query` to narrow down.\n 3) For the full step-by-step debug trace of a specific failure, set `include_trace:true` (response is much larger; you'll likely get a `file_path` to read the full JSON from disk).\n\nUse `environment:'live'` when investigating a production user complaint (excludes draft test runs). Use `environment:'draft'` when the user says 'I just tested X locally and it failed' (their local UI hits the draft overlay).",
470
+ description: "Search recent backend activity for the current project. ALWAYS call this FIRST when the user reports any error, bug, 'this isn't working', or a click/action that appears to do nothing — don't guess from the code; check what actually happened. Returns a unified, time-ordered list mixing workflow executions and warn/error log lines from the engine. Defaults to recent problems only (`status:'problem'`) over the last 24h. Raw data is retained 7 days and cleaned automatically by metrics-db retention policies.\n\nWorkflow:\n 1) Call with no args (or just `since:'1h'`) → see recent failures/warnings.\n 2) If the user says a button/action did nothing and there is no visible error → call `search_logs({ since:'30m', status:'all', endpoint:'...' })` or `status:'success'` to see successful backend calls too.\n 3) Pick the relevant entry → call again with `endpoint` + tighter `query` to narrow down.\n 4) For the full step-by-step debug trace of a specific failure, set `include_trace:true` (response is much larger; you'll likely get a `file_path` to read the full JSON from disk).\n\nUse `environment:'live'` when investigating a production user complaint (excludes draft overlay test runs). Use `environment:'draft'` when the user says 'I just tested X locally and it failed' (their local UI hits the draft overlay).",
441
471
  inputSchema: {
442
472
  type: "object",
443
473
  properties: {
444
474
  project_id: { type: "string", description: "Project UUID. Auto-detected for project tokens." },
445
- query: { type: "string", description: "Optional substring to match (case-insensitive) in error messages and log lines. e.g. 'timeout', 'OpenAI', 'permission denied'." },
475
+ query: { type: "string", description: "Optional substring to match (case-insensitive) in messages, endpoint names, statuses, request IDs, and log lines. e.g. 'timeout', 'OpenAI', 'permission denied'." },
446
476
  endpoint: { type: "string", description: "Optional endpoint name filter (e.g. 'create-order')." },
447
477
  since: { type: "string", default: "24h", description: "Time window: relative ('15m', '1h', '24h', '7d') or ISO 8601 timestamp. Default 24h. Hard cap: 7d (retention)." },
448
- level: { type: "string", enum: ["error", "warn", "all"], default: "all", description: "Filter by severity. 'error' includes failed/timeout executions + error logs. 'warn' is warning logs. 'all' (default) returns both." },
478
+ level: { type: "string", enum: ["error", "warn", "all"], default: "all", description: "Filter by severity. 'error' includes failed/timeout executions + error logs. 'warn' returns warning logs only. 'all' returns every severity allowed by `status`." },
479
+ status: { type: "string", enum: ["problem", "success", "error", "timeout", "all"], default: "problem", description: "Workflow execution status filter. 'problem' (default) keeps the old lightweight behavior: failed/timed-out executions plus warn/error logs. 'success' shows successful executions only. 'all' shows successful and failed executions plus warn/error logs. Use 'success' or 'all' when debugging a user action that produced no visible backend error." },
449
480
  environment: { type: "string", enum: ["live", "draft", "all"], default: "all", description: "live = production traffic only (excludes draft overlay test runs). draft = only requests through dev-<project_id>.dypai.dev. all = both. Use 'live' for real user bug reports." },
450
481
  limit: { type: "integer", default: 50, minimum: 1, maximum: 200, description: "Max items to return. Default 50, max 200." },
451
482
  include_trace: { type: "boolean", default: false, description: "Attach the full step-by-step debug trace per failed execution. Verbose — combine with `query`/`endpoint` filters and a low `limit`. If the response gets large, the local proxy writes it to disk and returns a file_path you can Read." }
@@ -520,6 +551,34 @@ DYPAI builds the **backend** (DB, auth, API, storage, realtime) for any client.
520
551
 
521
552
  For everything else, **the stack is decided. Start building.**
522
553
 
554
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
555
+ # PROJECT ACCESS PROFILE — classify the app once you know the product shape
556
+ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
557
+
558
+ DYPAI projects store a lightweight product access profile: whether the app is
559
+ public, private, mixed, admin-only, internal-user based, end-user-account based,
560
+ single-role, or multi-role. This helps later agents and platform UI reason
561
+ about login, first-run credentials, preview instructions, and route protection.
562
+
563
+ When you know the product shape, call
564
+ \`manage_project_access_profile(operation:"update", ...)\`. Do this as metadata
565
+ only; it does NOT replace implementing the actual auth UI, roles, protected
566
+ routes, tables, or endpoints.
567
+
568
+ Defaults:
569
+ - Private/admin app: \`app_visibility:"private"\`, \`auth_scope:"admin_only"\`,
570
+ \`role_model:"single_role"\`, \`has_admin_area:true\`, \`has_public_area:false\`,
571
+ \`has_end_user_accounts:false\`, \`root_requires_auth:true\`.
572
+ - Public site with admin panel: \`app_visibility:"mixed"\`,
573
+ \`auth_scope:"admin_only"\`, \`has_public_area:true\`, \`has_admin_area:true\`,
574
+ \`root_requires_auth:false\`.
575
+ - User portal / marketplace / SaaS: use \`auth_scope:"admin_and_end_users"\`
576
+ when both admins and customers log in, \`role_model:"multi_role"\` if there
577
+ are materially different permissions, and \`has_end_user_accounts:true\`.
578
+ - Public-only landing/docs/blog: \`app_visibility:"public"\`,
579
+ \`auth_scope:"none"\`, \`role_model:"none"\`, \`has_admin_area:false\`,
580
+ \`root_requires_auth:false\`.
581
+
523
582
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
524
583
  # BEFORE YOU DO ANYTHING — materialize the project locally
525
584
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -563,35 +622,122 @@ A freshly created project has **zero** local files. The create response gives yo
563
622
  **Rule of thumb: if you can't \`Read\` it from disk, you can't touch it. Sync before you guess.**
564
623
 
565
624
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
566
- # TALKING TO THE USER — plain language, not tool names
625
+ # TALKING TO THE USER — proactive, plain language, no internal machinery
567
626
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
568
627
 
569
- The user is **not a developer watching your tool calls**. They read only your prose. Never narrate actions by their technical name translate every tool call into the real-world outcome.
628
+ Assume most DYPAI users are non-technical. Many are not developers, do not know what an endpoint is, and should not need to learn DYPAI's internal workflow. The user should be able to say what they want in normal language; your job is to translate that into technical work, testing, and a guided path to publication.
629
+
630
+ The user sees only TWO meaningful states:
631
+
632
+ 1. **Ready to test / listo para probar** — the change is available in their preview and does not affect real users.
633
+ 2. **Published / publicado** — the change is live for real users.
634
+
635
+ Everything else is internal agent machinery. Do not make the user learn about \`dypai_push\`, drafts, overlays, workflow YAML, MCP tools, merge/publish mechanics, or endpoint staging unless they explicitly ask how it works under the hood.
636
+
637
+ ## Be proactive with non-technical users
638
+
639
+ Do not wait for the user to know the next step. They often only know the outcome they want.
640
+
641
+ When the user asks for a feature, bugfix, or change:
642
+
643
+ 1. Understand the desired product behavior.
644
+ 2. Choose a sensible implementation path.
645
+ 3. Make the change end-to-end when possible.
646
+ 4. If backend behavior changed, automatically save it to preview.
647
+ 5. Test what you can yourself.
648
+ 6. Give the user exactly one clear next action.
649
+ 7. Publish to production only after explicit approval.
650
+
651
+ Never ask:
652
+
653
+ - "Should I run dypai_push?"
654
+ - "Should I push the endpoints?"
655
+ - "Should I stage this draft?"
656
+ - "Should I merge/publish the draft?"
657
+ - "Should I call manage_drafts?"
658
+
659
+ Say instead:
660
+
661
+ - "Ya lo he dejado listo para que lo pruebes."
662
+ - "Pruébalo en la previsualización."
663
+ - "Todavía no afecta a tus usuarios reales."
664
+ - "Cuando me digas que está bien, lo publico."
665
+ - "¿Quieres que lo publique ya para tus usuarios?"
666
+
667
+ ## Internal workflow you MUST follow
668
+
669
+ After changing backend behavior, ALWAYS make the change available in preview before telling the user to test it.
670
+
671
+ Internally this means:
672
+
673
+ 1. edit backend files
674
+ 2. validate local backend changes
675
+ 3. save them to the preview environment
676
+ 4. test the preview version when practical
677
+ 5. then tell the user it is ready to try
678
+
679
+ Never ask the user whether to run the internal save-to-preview step. It is safe, reversible, and required for the user to test the actual change.
680
+
681
+ Publishing to production is different: it changes what real users see and ALWAYS needs explicit user approval.
682
+
683
+ ## If the user asks "how do I see it?"
684
+
685
+ If the user asks "how do I see it?", "where do I test it?", "what now?", "pero como veo esto", or similar, do not explain backend states. Give the shortest practical next action in human terms.
686
+
687
+ Good:
688
+
689
+ - "Abre la previsualización y prueba crear un pedido."
690
+ - "Ve al panel de administración y edita un producto."
691
+ - "Prueba iniciar sesión con este usuario."
692
+ - "Pulsa 'Crear producto'. Deberías ver el nuevo producto en la lista sin recargar."
693
+ - "Dime si lo ves bien y lo publico."
694
+
695
+ Bad:
696
+
697
+ - "Está en el draft overlay."
698
+ - "He hecho dypai_push."
699
+ - "Falta manage_drafts publish."
700
+ - "El endpoint está staged."
570
701
 
571
702
  ## Translation rule of thumb
572
703
 
573
- Replace the VERB with what the user perceives:
704
+ Replace tool-speak with the outcome the user perceives:
574
705
 
575
706
  | Instead of (tool-speak) | Say (human) |
576
707
  |---|---|
577
- | "Voy a hacer dypai_push / I'll call dypai_push" | "Voy a subir los cambios al borrador para que los pruebes" |
578
- | "Voy a ejecutar manage_drafts(publish)" | "Voy a publicar los cambios en producción" |
708
+ | "Voy a hacer dypai_push / I'll call dypai_push" | "Voy a dejar los cambios listos para que los pruebes" |
709
+ | "He hecho dypai_push" | "Ya lo he dejado listo para probar" |
710
+ | "Voy a ejecutar manage_drafts(publish)" | "Voy a publicarlo para tus usuarios" |
711
+ | "He mergeado el draft" | "He publicado los cambios" |
579
712
  | "Ejecutando dypai_pull" | "Un momento, sincronizo tu proyecto" |
580
713
  | "Calling manage_frontend(deploy)" | "Voy a desplegar la nueva versión de tu web" |
581
714
  | "Running execute_sql to add a column" | "Voy a añadir un campo nuevo a la tabla X" |
582
715
  | "manage_database(operation:'apply_migration')" | "Voy a aplicar los cambios de estructura a la base de datos" |
583
716
  | "search_logs returned a 500" | "He mirado los registros: el error viene de..." |
584
717
  | "manage_drafts(list) shows 3 pending" | "Tienes 3 cambios pendientes de publicar" |
585
- | "On the draft overlay" / "en la overlay de draft" | "En tu entorno de previsualización" o "en el borrador" |
718
+ | "On the draft overlay" / "en la overlay de draft" | "En tu entorno de previsualización" |
586
719
  | "dypai_validate rejected the workflow" | "Hay un problema con la configuración:" |
587
720
 
588
721
  ## Principles
589
722
 
590
- 1. **State outcomes, not function calls.** The user cares about *"guardado"*, *"publicado"*, *"desplegado"*, *"probado"* — not *"pushed"*, *"dispatched"*, *"invalidated"*.
591
- 2. **Draft vs liveborrador vs producción / previsualización vs en vivo.** Never say "overlay", "engine", "endpoint hit" in user-facing prose.
723
+ 1. **State outcomes, not function calls.** The user cares about *"listo para probar"*, *"publicado"*, *"desplegado"*, *"probado"* — not *"pushed"*, *"staged"*, *"invalidated"*, or *"merged"*.
724
+ 2. **Preview vs productionprevisualización vs producción.** Prefer *"listo para probar"* and *"publicado"*. Avoid *"draft"*, *"borrador"*, *"mergear draft"*, *"overlay"*, *"engine"*, and *"endpoint hit"* in user-facing prose unless the user used those words first.
592
725
  3. **Errors: summarize, don't paste.** *"Falló porque estás comparando tipos distintos en la SQL"* beats pasting a Postgres stack.
593
- 4. **Confirmations use real-world verbs.** *"¿Lo publico a producción?"* not *"¿Ejecuto manage_drafts(publish, confirm:true)?"*.
594
- 5. **Progress updates in sentences, not tool names.** *"Primero guardo los cambios, luego te los muestro para que los pruebes, y si te cuadra los publicamos"* beats a 3-bullet list of tool calls.
726
+ 4. **Confirmations use real-world verbs.** *"¿Lo publico para tus usuarios?"* not *"¿Ejecuto manage_drafts(publish, confirm:true)?"*.
727
+ 5. **Progress updates in sentences, not tool names.** *"Estoy haciendo el cambio; cuando esté listo te digo exactamente dónde probarlo"* beats a bullet list of tool calls.
728
+ 6. **End every completed change with one practical next step.** Examples: *"Pruébalo en la previsualización"*, *"Dime si quieres que lo publique"*, *"Prueba hacer una compra con tarjeta de test"*.
729
+
730
+ ## Preferred user-facing phrases
731
+
732
+ Use phrases like:
733
+
734
+ - "Estoy haciendo el cambio."
735
+ - "Ya lo he dejado listo para que lo pruebes."
736
+ - "Abre la previsualización y prueba este flujo concreto: ..."
737
+ - "Todavía no afecta a tus usuarios reales."
738
+ - "Cuando me confirmes que está bien, lo publico."
739
+ - "He revisado el error en los registros y viene de..."
740
+ - "He actualizado la base de datos para añadir el nuevo campo."
595
741
 
596
742
  ## When you CAN mention a tool name
597
743
 
@@ -645,6 +791,17 @@ synonym.
645
791
  - \`"agent ai"\` — agent node: providers, tools, memory, streaming, tool endpoints
646
792
  - \`"javascript code node"\` — custom code escape hatch when native nodes don't fit
647
793
 
794
+ ### Managed AI Models
795
+ - Before creating or editing any AI Agent node that uses DYPAI Managed AI,
796
+ always call \`list_ai_models\` first.
797
+ - Use only model IDs from \`list_ai_models.models[].id\`; never invent OpenRouter
798
+ model IDs.
799
+ - For DYPAI Managed AI, set \`provider: "openrouter"\` and do not set
800
+ \`credential_id\`; DYPAI uses the platform key server-side.
801
+ - Treat model pricing as AI Credits, using
802
+ \`input_ai_credits_per_million\` / \`output_ai_credits_per_million\` when you
803
+ need to explain cost.
804
+
648
805
  ### Auth
649
806
  - \`"auth flows"\` — signup / login / reset / magic link / role upgrade canonical flows
650
807
  - \`"auth defaults"\` — what auth_mode to pick when the user doesn't specify
@@ -713,12 +870,13 @@ Editing files inside \`dypai/\` only changes YOUR DISK. The platform doesn't see
713
870
  \`\`\`
714
871
 
715
872
  Practical consequences — internalize these:
716
- - **After EVERY meaningful change set, call \`dypai_push\`.** Don't batch a session's worth of edits hoping to push at the end — if you forget, the user tests the local UI and sees the OLD behavior, gets confused, and you waste a debug round-trip blaming the code. The push is cheap, idempotent, and creates ONE draft per resource (subsequent pushes overwrite the draft, not stack new ones).
717
- - **\`dypai_push\` is the "save" button. It is NOT a publish.** Live traffic is untouched. You can push 20 times in a row without affecting a single user. Tell the user that explicitly when they ask "did it ship?" push = staged draft, publish = live.
718
- - **The draft overlay (\`dev-<project_id>.dypai.dev\`) only sees what you've pushed.** A change still on disk is invisible to the local frontend. If the user says "I tested it locally and nothing changed", your first check is "did I run \`dypai_push\` after the last edit?".
873
+ - **Never publish backend changes just to test them.** Backend changes are testable before production: save them to preview, verify with \`dypai_test_endpoint(mode:'draft')\` when possible, then tell the user exactly what to try in preview.
874
+ - **After EVERY meaningful backend change set, call \`dypai_push\`.** Don't batch a session's worth of edits hoping to push at the end if you forget, the user tests the preview and sees the OLD behavior. The push is cheap, idempotent, and creates ONE preview version per resource (subsequent pushes overwrite the pending preview version, not stack new ones).
875
+ - **\`dypai_push\` is the internal save-to-preview step. It is NOT a production publish.** Live traffic is untouched. You can run it repeatedly without affecting real users. In user-facing prose, say "listo para probar" or "en previsualización", not "pushed" or "draft".
876
+ - **The preview host (\`dev-<project_id>.dypai.dev\`) only sees what you've saved to preview.** A change still only on disk is invisible to the user's preview. If the user says "I tested it and nothing changed", first check whether the backend change was saved to preview after the last edit.
719
877
  - **\`dypai_validate\` before \`dypai_push\`** — push runs validate as a pre-flight, but running it explicitly first gives you the lint output without committing. Cheap insurance.
720
- - **Order during a multi-step feature**: edit → \`dypai_validate\` → \`dypai_push\` → \`dypai_test_endpoint(mode:'draft')\` (or tell the user to test their local UI). Repeat per change. ONLY at the end, when the user signs off, do \`manage_drafts(operation:'list')\` → \`manage_drafts(operation:'publish', confirm:true)\`.
721
- - **DDL is the exception**: \`execute_sql\` with CREATE / ALTER / DROP TABLE applies to live IMMEDIATELY (no draft stage for schema). Drafts only exist for endpoints / webhooks / crons / realtime policies. Summarize destructive DDL to the user before running it.
878
+ - **Order during a multi-step backend feature**: edit → \`dypai_validate\` → \`dypai_push\` → \`dypai_test_endpoint(mode:'draft')\` (or tell the user to test preview). Repeat per change. ONLY when the user explicitly approves production do \`manage_drafts(operation:'list')\` → \`manage_drafts(operation:'publish', confirm:true)\`.
879
+ - **DDL is the exception**: \`execute_sql\` with CREATE / ALTER / DROP TABLE applies to live IMMEDIATELY (no preview layer for schema). Preview only exists for endpoints / webhooks / crons / realtime policies. Summarize destructive DDL to the user before running it.
722
880
 
723
881
  ## User intent → tool to call (decision table)
724
882
 
@@ -728,13 +886,14 @@ Use this BEFORE picking a tool. If unsure which row matches, ask the user.
728
886
  |---|---|---|
729
887
  | "Create a new project" | \`search_project_templates\` (find a starter) | \`create_project(template_slug: ...)\` |
730
888
  | "Show me what we have" / "I want to work on existing project X" | \`list_projects\` → \`dypai_pull\` (backend) + \`manage_frontend(sync)\` (frontend) | Read \`dypai/\` files + \`src/\` |
889
+ | "This is a private admin app / public site / user portal / multi-role app" | \`manage_project_access_profile(operation:'update')\` | Then implement the actual auth/UI/data behavior normally |
731
890
  | "Add/change a backend endpoint, table, cron, webhook, agent, integration" | Edit files in \`dypai/\` | \`dypai_validate\` → \`dypai_push\` |
732
891
  | "Publish my backend changes" / "make it live" | \`manage_drafts(operation:'list')\` to show what's pending | \`manage_drafts(operation:'publish', confirm:true)\` |
733
892
  | "Test an endpoint before publishing" | \`dypai_test_endpoint(mode:'local')\` (your edits) or \`(mode:'draft')\` (after push) | — |
734
893
  | "Test the new endpoint from my local frontend, end-to-end, before publishing" | Tell user: their local frontend already points to \`https://dev-<project_id>.dypai.dev\` (set by \`manage_frontend(sync)\`), which serves drafts on top of live. So after \`dypai_push\` the local UI hits the draft overlay automatically — nothing else to do. | — |
735
894
  | "Throw away my backend changes" | \`manage_drafts(operation:'discard', confirm:true)\` | — |
736
- | "Change the UI / change colors / add a page" | Edit files in \`src/\` | \`manage_frontend(deploy, confirm:true)\` |
737
- | "Publish the new UI" / "ship the frontend" | \`manage_frontend(deploy, confirm:true)\` | (deploy is the publish there is no separate step) |
895
+ | "Change the UI / change colors / add a page" | Edit files in \`src/\` | Test locally/with browser when possible. \`manage_frontend(deploy, confirm:true)\` only when the user approves publishing the UI. |
896
+ | "Publish the new UI" / "ship the frontend" | If backend changes are needed by the new UI, publish those backend changes first with explicit approval | Then \`manage_frontend(deploy, confirm:true)\` deploy is live, not a preview. |
738
897
  | "Roll back" | Backend: \`get_endpoint_versions\` then write old code back. Frontend: re-deploy older source. | — |
739
898
  | "Upload a file / a CSV / seed data" | \`bulk_upsert\` (data) or \`manage_storage(upload_file)\` (binary) | — |
740
899
  | "X is broken" / "I'm getting an error" / "this doesn't work" / "users are reporting Y" | \`search_logs\` FIRST (don't guess from the code) | If a specific failure is found → \`search_logs(include_trace:true, query:'...')\` for the full step-by-step trace |
@@ -760,19 +919,21 @@ User: "Add a /api/list-tasks endpoint that returns the current user's tasks, and
760
919
  2. manage_frontend(operation:'sync', ...) # materialize frontend if not already on disk
761
920
  3. # Backend: create the endpoint
762
921
  Write dypai/endpoints/list-tasks.yaml # trigger.http_api auth_mode:jwt + dypai_database query
763
- 4. dypai_validate # catch typos before push
764
- 5. dypai_push # stages as draft, NOT live yet
765
- 6. dypai_test_endpoint(name:'list-tasks', mode:'draft', as_user:'<uuid>') # verify the staged version
766
- 7. manage_drafts(operation:'list') # show pending changes to user
767
- 8. # ASK USER: "Ready to publish list-tasks to live?"
768
- 9. manage_drafts(operation:'publish', confirm:true) # backend now live ✅
769
- 10. # Frontend: call the new endpoint from React
770
- Edit src/pages/Dashboard.tsx # useEndpoint('list-tasks')
771
- 11. # ASK USER: "Ready to deploy the dashboard UI?"
772
- 12. manage_frontend(operation:'deploy', sourceDirectory, confirm:true) # frontend now live
922
+ 4. dypai_validate # catch typos before saving to preview
923
+ 5. dypai_push # saves to preview, NOT production
924
+ 6. dypai_test_endpoint(endpoint:'list-tasks', mode:'draft', as_user:'<user_id>')
925
+ # verifies the preview version; do NOT publish just to test
926
+ 7. # Frontend: call the new endpoint from React
927
+ Edit src/pages/Dashboard.tsx # useEndpoint('list-tasks')
928
+ 8. # Test locally/browser if available. Then tell the user in plain language:
929
+ # "Ya está listo para probar. Abre la previsualización y revisa la lista de tareas. Todavía no está publicado para tus usuarios."
930
+ 9. # ONLY after the user confirms it is good:
931
+ manage_drafts(operation:'list') # internal: inspect what will publish
932
+ 10. manage_drafts(operation:'publish', confirm:true) # backend live after explicit approval
933
+ 11. manage_frontend(operation:'deploy', sourceDirectory, confirm:true) # frontend live after explicit approval
773
934
  \`\`\`
774
935
 
775
- **Order matters**: publish backend BEFORE deploying frontend. Otherwise the new UI calls an endpoint that doesn't exist on live yet 404s for users. The \`manage_frontend(deploy)\` confirmation hint will warn you if backend drafts are still pending.
936
+ **Testing rule**: never publish backend changes just to test them. Backend can be verified from the preview version. **Production order rule**: when you are truly publishing a full-stack change, publish backend BEFORE deploying the frontend; otherwise the live UI may call backend functionality that is not live yet.
776
937
 
777
938
  ## Debugging user-reported errors — \`search_logs\` is your starting point
778
939
 
@@ -789,10 +950,16 @@ User: "Add a /api/list-tasks endpoint that returns the current user's tasks, and
789
950
  # Did they say "production users are reporting..."?
790
951
  # → add environment: "live" (excludes their own draft test runs)
791
952
 
792
- 3. # Found the relevant entry? Narrow down:
953
+ 3. # Did they say "I clicked the button and it disappeared / nothing happened"
954
+ # and the error scan is empty?
955
+ search_logs({ since: "30m", status: "all", endpoint: "<likely-endpoint>" })
956
+ → This shows successful executions too, so you can tell whether the backend
957
+ was called, returned 200, was slow, or was never reached.
958
+
959
+ 4. # Found the relevant entry? Narrow down:
793
960
  search_logs({ endpoint: "create-order", query: "stripe", since: "1h" })
794
961
 
795
- 4. # For the full step-by-step trace of one specific failure:
962
+ 5. # For the full step-by-step trace of one specific failure:
796
963
  search_logs({
797
964
  endpoint: "create-order",
798
965
  query: "<a unique substring from the error message>",
@@ -803,12 +970,12 @@ User: "Add a /api/list-tasks endpoint that returns the current user's tasks, and
803
970
  and returns a \`file_path\`. Read that file with the Read tool ONLY
804
971
  when you need fields beyond the inline summary.
805
972
 
806
- 5. # Now you know exactly which node failed and why → fix the code.
973
+ 6. # Now you know exactly which node failed, succeeded, or was never called → fix the code.
807
974
  \`\`\`
808
975
 
809
976
  ### What \`search_logs\` returns
810
977
 
811
- Each item has \`type\` (\`execution_failed\` | \`log\`), \`level\` (\`error\` | \`warn\`), \`time\`, \`endpoint\`, \`message\`, and \`environment\` (\`live\` | \`draft\` | null for legacy rows). Failed executions also include \`status\` (\`error\` | \`timeout\`) and \`duration_ms\`. With \`include_trace:true\` they also include \`trace\` — a per-node log of inputs, outputs, errors, and stacks.
978
+ Each item has \`type\` (\`execution\` | \`execution_failed\` | \`log\`), \`level\` (\`info\` | \`error\` | \`warn\`), \`time\`, \`endpoint\`, \`message\`, and \`environment\` (\`live\` | \`draft\` | null for legacy rows). Workflow executions include \`status\` (\`success\` | \`error\` | \`timeout\`) and \`duration_ms\`. With \`include_trace:true\`, failed executions can include \`trace\` — a per-node log of inputs, outputs, errors, and stacks.
812
979
 
813
980
  ### Common pitfalls
814
981
 
@@ -857,6 +1024,7 @@ Mental translations: "edge function" → workflow with one code node; "cron" →
857
1024
  4. **\`public\` auth_mode with \`\${current_user_id}\`** — no JWT → placeholder empty → SQL fails or returns wrong data. Use \`jwt\` if you need the user.
858
1025
  5. **Missing \`return: true\`** — endpoint returns \`null\`. Every path that should produce an HTTP response needs one node with \`return: true\`.
859
1026
  6. **\`tool_ids\` in YAML instead of \`tools\`** — write \`tools: [name1, name2]\`. \`tool_ids\` bypasses the codec and fails silently in prod.
1027
+ 7. **Putting workflow placeholders inside \`javascript_code.code\`** — code is raw JavaScript, so JS template literals like \`\${where.join(" AND ")}\` are safe and not rendered by DYPAI. Pass workflow values via \`input_data\`, \`ctx.nodes\`, \`ctx.user\`, or \`ctx.env\`; do not write \`\${input.email}\` inside code or set \`code\` from another node output.
860
1028
 
861
1029
  → Longer list of common pitfalls + fixes: \`search_docs("troubleshooting")\`.
862
1030
 
@@ -32,7 +32,7 @@ export const manageFrontendTool = {
32
32
  "AFTER SYNC: .env is gitignored so it's NOT included in the download. If the target directory has no .env, the response sets `env_file_missing: true` and adds a `next_steps` line with the exact VITE_DYPAI_URL / NEXT_PUBLIC_DYPAI_URL value to write. Follow it — without .env the SDK can't reach the engine.\n" +
33
33
  " - deploy: Upload source files from a local directory and queue a build. **DESTRUCTIVE: replaces the LIVE site immediately, no draft stage, no rollback button.** " +
34
34
  "Requires `confirm: true` — without it the tool returns a confirmation_required hint instead of deploying. " +
35
- "If backend drafts are pending, the hint includes a warning to publish backend FIRST (otherwise the new frontend may call endpoints that don't exist yet). " +
35
+ "If backend drafts are pending, the hint warns that publishing the frontend is a live production action; only publish backend FIRST when the user has explicitly approved going live. Do NOT publish backend just to test it — use preview/draft testing instead. " +
36
36
  "Returns immediately with build_status=\"queued\" — poll with `build_status` until \"success\" or \"failure\". " +
37
37
  "The response includes `build_quota` with remaining monthly build minutes. If minutes_remaining is low, tell the user. If 0, DO NOT retry — suggest upgrading the plan.\n" +
38
38
  " - status: Current live deploy info (URL, last deploy time, size).\n" +
@@ -41,7 +41,7 @@ export const manageFrontendTool = {
41
41
  " - list_deployments: Recent deploy history (status, commit, duration, URL).\n" +
42
42
  " - logs: Build logs for a specific deployment (needs deployment_id from list_deployments).\n\n" +
43
43
  "Related: `dypai_pull` brings BACKEND state (YAML endpoints, SQL, prompts). The two are independent — run both when starting fresh on a full-stack project.\n\n" +
44
- "Order rule when both backend AND frontend changed: 1) dypai_push (saves backend as draft) → 2) manage_drafts(publish, confirm:true) → 3) manage_frontend(deploy, confirm:true). Inverting steps 2 and 3 may serve a frontend that calls non-existent endpoints.",
44
+ "Testing rule: backend changes can be tested in preview after dypai_push; do NOT publish backend just to test. Production order rule when both backend AND frontend changed and the user approved going live: 1) publish backend drafts with manage_drafts(publish, confirm:true) → 2) deploy frontend with manage_frontend(deploy, confirm:true). Inverting production order may serve a live frontend that calls backend functionality not live yet.",
45
45
 
46
46
  inputSchema: {
47
47
  type: "object",
@@ -117,7 +117,7 @@ export const manageFrontendTool = {
117
117
  const draftsTotal = draftsResult?.total || 0
118
118
  if (draftsTotal > 0) {
119
119
  warnings.push(
120
- `${draftsTotal} backend draft(s) pending. Publish them BEFORE deploying the frontend with manage_drafts(operation:'publish', confirm:true) otherwise the new frontend may call endpoints that don't exist on live yet.`,
120
+ `${draftsTotal} backend preview change(s) pending. This frontend deploy is LIVE. Only publish the backend first if the user explicitly approved production; for testing, keep backend in preview and test without deploying live frontend.`,
121
121
  )
122
122
  }
123
123
  } catch {
@@ -130,6 +130,7 @@ export function maybeOffloadSearchLogs(result) {
130
130
  project_id: result.project_id,
131
131
  since: result.since,
132
132
  level: result.level,
133
+ status: result.status,
133
134
  environment: result.environment,
134
135
  endpoint: result.endpoint,
135
136
  query: result.query,
@@ -524,7 +524,7 @@ export const dypaiPushTool = {
524
524
  next_step: errors.length
525
525
  ? "Fix the offending YAMLs and push again."
526
526
  : draftCount > 0
527
- ? `${draftCount} change(s) saved as draft(s) — they're not live yet. Review with manage_drafts(operation:'list'), verify with dypai_test_endpoint(mode:'draft', endpoint:'<name>'), then publish with manage_drafts(operation:'publish', confirm:true) (or discard with manage_drafts(operation:'discard', confirm:true) to throw them away).`
527
+ ? `${draftCount} change(s) saved to preview — they're not live yet. Verify with dypai_test_endpoint(mode:'draft', endpoint:'<name>') or ask the user to test the preview. Publish with manage_drafts(operation:'publish', confirm:true) ONLY after explicit approval.`
528
528
  : changedNames.length
529
529
  ? `Test changed endpoint(s) with dypai_test_endpoint: ${changedNames.slice(0, 3).join(", ")}${changedNames.length > 3 ? "…" : ""}`
530
530
  : undefined,
@@ -361,6 +361,9 @@ export const dypaiTestEndpointTool = {
361
361
  data: input,
362
362
  trace_mode, // used by the local MCP enrichment layer
363
363
  }
364
+ if (mode === "local") execArgs.draft_mode = true
365
+ if (mode === "draft") execArgs.draft_mode = true
366
+ if (mode === "live") execArgs.draft_mode = false
364
367
  if (as_user) execArgs.impersonated_user_id = as_user
365
368
 
366
369
  // Build a compact source-meta block for the response so the agent can see
@@ -8,6 +8,7 @@
8
8
  * - `credential: foo` pointing to a credential that doesn't exist remotely
9
9
  * - Agent `tools: [foo]` pointing to endpoints that aren't tools
10
10
  * - SQL queries referencing tables that don't exist in schema.sql
11
+ * - dypai_storage/static storage bucket references that don't exist remotely
11
12
  *
12
13
  * Returns a list of diagnostics with severity + fix_hint. Non-zero errors
13
14
  * mean push will refuse (unless skip_validation is passed).
@@ -179,6 +180,44 @@ function* walkStrings(node, path = "") {
179
180
  }
180
181
  }
181
182
 
183
+ const BUCKET_LOC_RE = /(?:^|\.)(?:bucket|bucket_name|bucketName|storage_bucket|storageBucket)$/
184
+ const STATIC_BUCKET_RE = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/
185
+
186
+ function isStaticBucketName(value) {
187
+ const trimmed = String(value || "").trim()
188
+ if (!trimmed || trimmed.includes("${") || trimmed.includes("{{")) return false
189
+ return STATIC_BUCKET_RE.test(trimmed)
190
+ }
191
+
192
+ function collectStorageBucketRefs(entry, endpoint, file) {
193
+ const refs = new Map()
194
+ const add = (bucket, loc) => {
195
+ const name = String(bucket || "").trim()
196
+ if (!isStaticBucketName(name)) return
197
+ refs.set(`${name}|${loc}`, { bucket: name, endpoint, file, loc })
198
+ }
199
+
200
+ for (const { path, value } of walkStrings(entry.doc)) {
201
+ if (BUCKET_LOC_RE.test(path)) add(value, path)
202
+
203
+ // JS helper pattern from workflow code files/inline code:
204
+ // storage.upload("documents", ...), storage.read("documents", ...), etc.
205
+ if (/(^|\.|])code$/.test(path)) {
206
+ const re = /\bstorage\.(?:upload|read|list|getUrl|delete)\(\s*["']([a-z0-9][a-z0-9-]{1,61}[a-z0-9])["']/g
207
+ let m
208
+ while ((m = re.exec(value)) !== null) add(m[1], path)
209
+ }
210
+ }
211
+
212
+ for (const [filePath, content] of Object.entries(entry.fileMap || {})) {
213
+ const re = /\bstorage\.(?:upload|read|list|getUrl|delete)\(\s*["']([a-z0-9][a-z0-9-]{1,61}[a-z0-9])["']/g
214
+ let m
215
+ while ((m = re.exec(content)) !== null) add(m[1], filePath)
216
+ }
217
+
218
+ return [...refs.values()]
219
+ }
220
+
182
221
  /** Extract all ${...} placeholder expressions from a string. */
183
222
  function extractPlaceholders(s) {
184
223
  const out = []
@@ -188,6 +227,13 @@ function extractPlaceholders(s) {
188
227
  return out
189
228
  }
190
229
 
230
+ function isRawCodePlaceholderSource(source, loc) {
231
+ if (source === "file") {
232
+ return loc.startsWith("code/") || /\.(js|ts|py)$/.test(loc)
233
+ }
234
+ return /(^|\.|])code$/.test(loc)
235
+ }
236
+
191
237
  /**
192
238
  * Extract the first identifier (a-z, A-Z, _, 0-9 — JS-ident shape) from the
193
239
  * head of an expression, ignoring any trailing template filter or coalesce
@@ -289,6 +335,31 @@ const LEGACY_OPS_THAT_USE_TABLE_FIELD = new Set([
289
335
  "select", "insert", "update", "delete", "upsert", "aggregate",
290
336
  ])
291
337
 
338
+ function nodeParams(node) {
339
+ return node?.parameters && typeof node.parameters === "object" && !Array.isArray(node.parameters)
340
+ ? node.parameters
341
+ : null
342
+ }
343
+
344
+ function nodeField(node, params, key) {
345
+ return node?.[key] ?? params?.[key]
346
+ }
347
+
348
+ function lookupFileMapContent(fileMap, ref) {
349
+ if (!fileMap || typeof ref !== "string" || !ref.trim()) return ""
350
+ const key = ref.trim()
351
+ return fileMap[key] || fileMap[key.replace(/^dypai\//, "")] || fileMap[`dypai/${key}`] || ""
352
+ }
353
+
354
+ function collectMissingTableCandidates(ctx, referencedTables, endpoint, file) {
355
+ if (!ctx.schemaTables) return
356
+ for (const table of referencedTables) {
357
+ if (!ctx.schemaTables.has(table)) {
358
+ ctx.suspectedMissingTables.push({ table, endpoint, file })
359
+ }
360
+ }
361
+ }
362
+
292
363
  // ─── Rules ──────────────────────────────────────────────────────────────────
293
364
 
294
365
  function ruleUsesJwt(trigger) {
@@ -307,17 +378,24 @@ function validateEndpoint(entry, ctx) {
307
378
 
308
379
  const jwt = ruleUsesJwt(doc.trigger)
309
380
 
310
- // Collect all strings INCLUDING file contents (query_file, system_prompt_file, code_file)
381
+ // Collect all template-rendered strings, including query_file and prompts.
382
+ // Raw code is intentionally skipped: javascript_code.code/code_file may
383
+ // contain normal JS template literals such as `${where.join(" AND ")}`.
311
384
  const sources = []
312
385
  for (const { path, value } of walkStrings(doc)) {
386
+ if (isRawCodePlaceholderSource("yaml", path)) continue
313
387
  sources.push({ source: "yaml", loc: path, value })
314
388
  }
315
389
  for (const [filePath, content] of Object.entries(fileMap || {})) {
390
+ if (isRawCodePlaceholderSource("file", filePath)) continue
316
391
  sources.push({ source: "file", loc: filePath, value: content })
317
392
  }
318
393
 
319
394
  // Collect SQL tables referenced (before checking each individually)
320
395
  const referencedTables = new Set()
396
+ for (const ref of collectStorageBucketRefs(entry, name, file)) {
397
+ ctx.suspectedStorageBuckets.push(ref)
398
+ }
321
399
 
322
400
  // Aggregate missing input.X refs across the whole endpoint so we emit ONE
323
401
  // diagnostic per endpoint instead of N (an endpoint with 11 stray refs
@@ -457,23 +535,6 @@ function validateEndpoint(entry, ctx) {
457
535
  }
458
536
  }
459
537
 
460
- // --- SQL table existence (if schema.sql available) ---
461
- // Defer the actual error emission to runValidation: it batch-checks all
462
- // missing tables against the remote in ONE query before deciding what's
463
- // a real error vs a stale-local-schema. We just collect the misses here
464
- // along with enough context for runValidation to emit per-endpoint errors.
465
- if (ctx.schemaTables) {
466
- for (const table of referencedTables) {
467
- if (!ctx.schemaTables.has(table)) {
468
- ctx.suspectedMissingTables.push({
469
- table,
470
- endpoint: name,
471
- file,
472
- })
473
- }
474
- }
475
- }
476
-
477
538
  // --- Per-node catalog-based validation (unknown type, missing/unknown params) ---
478
539
  for (const node of doc.workflow?.nodes || []) {
479
540
  if (!node || typeof node !== "object") continue
@@ -504,30 +565,35 @@ function validateEndpoint(entry, ctx) {
504
565
 
505
566
  // dypai_database — coherence checks for the new canonical operations.
506
567
  if (nodeType === "dypai_database") {
507
- const op = node.operation
568
+ const params = nodeParams(node)
569
+ const op = nodeField(node, params, "operation")
570
+ const table = nodeField(node, params, "table") ?? nodeField(node, params, "table_name")
571
+ const query = nodeField(node, params, "query")
572
+ const queryFile = nodeField(node, params, "query_file")
573
+ const insertValue = nodeField(node, params, "insert")
574
+ const updateValue = nodeField(node, params, "update")
575
+ const deleteValue = nodeField(node, params, "delete")
576
+ const whereValue = nodeField(node, params, "where")
508
577
 
509
578
  // Extract referenced tables from real SQL fields ONLY. Doing this here
510
579
  // (instead of in the generic walkStrings pass) eliminates the class of
511
580
  // false positive where a prompt/comment/label happens to contain words
512
581
  // like "INSERT" or "SELECT". Also covers `mutation` (table: <name>)
513
582
  // since that's a guaranteed table reference.
514
- if (op === "query" || (op && LEGACY_OPS_THAT_USE_QUERY.has(op))) {
515
- const sqlText = typeof node.query === "string" ? node.query : ""
516
- if (sqlText) {
517
- for (const t of extractSqlTables(sqlText)) referencedTables.add(t)
518
- }
583
+ const sqlTexts = []
584
+ if (typeof query === "string" && query.trim()) sqlTexts.push(query)
585
+ const queryFileSql = lookupFileMapContent(fileMap, queryFile)
586
+ if (queryFileSql) sqlTexts.push(queryFileSql)
587
+ for (const sqlText of sqlTexts) {
588
+ for (const t of extractSqlTables(sqlText)) referencedTables.add(t)
519
589
  }
520
- if (op === "mutation" && typeof node.table === "string") {
521
- referencedTables.add(node.table)
590
+ if (op === "mutation" && typeof table === "string") {
591
+ referencedTables.add(table)
522
592
  }
523
593
  // Legacy ops like `select` / `insert` / `update` / `delete` use `table:`
524
594
  // as the target table directly.
525
- if (op && LEGACY_OPS_THAT_USE_TABLE_FIELD.has(op) && typeof node.table === "string") {
526
- referencedTables.add(node.table)
527
- }
528
- // Resolved query_file content also counts as SQL (loaded by the codec).
529
- if (typeof node.query === "string" && node.query.length > 0) {
530
- for (const t of extractSqlTables(node.query)) referencedTables.add(t)
595
+ if (op && LEGACY_OPS_THAT_USE_TABLE_FIELD.has(op) && typeof table === "string") {
596
+ referencedTables.add(table)
531
597
  }
532
598
  const LEGACY_OPS = new Set(["select", "insert", "update", "delete", "upsert", "aggregate", "copy_to", "custom_query"])
533
599
  if (op && LEGACY_OPS.has(op)) {
@@ -549,7 +615,7 @@ function validateEndpoint(entry, ctx) {
549
615
  // Fields like `query`, `query_file`, `params` belong to `operation: query`
550
616
  // and are silently ignored here — surface as ERROR so the agent reroutes.
551
617
  if (op === "mutation") {
552
- if (!node.table) {
618
+ if (!table) {
553
619
  diagnostics.push({
554
620
  severity: "error",
555
621
  rule: "mutation_missing_table",
@@ -558,9 +624,9 @@ function validateEndpoint(entry, ctx) {
558
624
  fix_hint: `Add 'table: <name>'. mutation also needs exactly one of: insert / update / delete.`,
559
625
  })
560
626
  }
561
- const wantsInsert = node.insert !== undefined && node.insert !== null
562
- const wantsUpdate = node.update !== undefined && node.update !== null
563
- const wantsDelete = node.delete === true
627
+ const wantsInsert = insertValue !== undefined && insertValue !== null
628
+ const wantsUpdate = updateValue !== undefined && updateValue !== null
629
+ const wantsDelete = deleteValue === true
564
630
  const opCount = [wantsInsert, wantsUpdate, wantsDelete].filter(Boolean).length
565
631
  if (opCount === 0) {
566
632
  diagnostics.push({
@@ -582,7 +648,7 @@ function validateEndpoint(entry, ctx) {
582
648
  if (wantsUpdate || wantsDelete) {
583
649
  // `where: {}` is just as dangerous as omitting `where:` entirely — both
584
650
  // produce an unconstrained UPDATE/DELETE in the engine.
585
- const whereVal = node.where
651
+ const whereVal = whereValue
586
652
  const whereIsEmpty =
587
653
  whereVal === undefined ||
588
654
  whereVal === null ||
@@ -603,7 +669,7 @@ function validateEndpoint(entry, ctx) {
603
669
  // Foreign fields that belong to `operation: query`
604
670
  const QUERY_ONLY = ["query", "query_file", "params"]
605
671
  for (const k of QUERY_ONLY) {
606
- if (node[k] !== undefined) {
672
+ if (node[k] !== undefined || params?.[k] !== undefined) {
607
673
  diagnostics.push({
608
674
  severity: "error",
609
675
  rule: "mutation_with_query_field",
@@ -620,7 +686,7 @@ function validateEndpoint(entry, ctx) {
620
686
  // Fields like `table`, `insert`, `update`, `delete`, `where`, `on_conflict`,
621
687
  // `returning` belong to `operation: mutation` and are ignored here.
622
688
  if (op === "query") {
623
- if (!node.query && !node.query_file) {
689
+ if (!query && !queryFile) {
624
690
  diagnostics.push({
625
691
  severity: "error",
626
692
  rule: "query_missing_sql",
@@ -630,7 +696,10 @@ function validateEndpoint(entry, ctx) {
630
696
  })
631
697
  }
632
698
  const MUTATION_ONLY = ["table", "insert", "update", "delete", "where", "on_conflict", "returning"]
633
- const foreign = MUTATION_ONLY.filter(k => node[k] !== undefined && node[k] !== false)
699
+ const foreign = MUTATION_ONLY.filter(k => {
700
+ const value = node[k] ?? params?.[k]
701
+ return value !== undefined && value !== false
702
+ })
634
703
  if (foreign.length > 0) {
635
704
  diagnostics.push({
636
705
  severity: "error",
@@ -855,6 +924,11 @@ function validateEndpoint(entry, ctx) {
855
924
  }
856
925
  }
857
926
 
927
+ // --- SQL table existence (if schema.sql available) ---
928
+ // Defer actual error emission to runValidation: it batch-checks all missing
929
+ // tables against the remote before deciding whether local schema.sql is stale.
930
+ collectMissingTableCandidates(ctx, referencedTables, name, file)
931
+
858
932
  // --- Credential references ---
859
933
  for (const node of doc.workflow?.nodes || []) {
860
934
  if (!node || typeof node !== "object") continue
@@ -982,14 +1056,15 @@ function validateEndpoint(entry, ctx) {
982
1056
  for (const node of allNodes) {
983
1057
  const nodeType = node?.type ?? node?.node_type
984
1058
  if (nodeType !== "dypai_database") continue
985
- const op = node.operation
1059
+ const params = nodeParams(node)
1060
+ const op = nodeField(node, params, "operation")
986
1061
  let writeKind = null
987
1062
  if (op === "mutation") {
988
- if (node.insert !== undefined && node.insert !== null) writeKind = "INSERT"
989
- else if (node.update !== undefined && node.update !== null) writeKind = "UPDATE"
990
- else if (node.delete === true) writeKind = "DELETE"
1063
+ if (nodeField(node, params, "insert") !== undefined && nodeField(node, params, "insert") !== null) writeKind = "INSERT"
1064
+ else if (nodeField(node, params, "update") !== undefined && nodeField(node, params, "update") !== null) writeKind = "UPDATE"
1065
+ else if (nodeField(node, params, "delete") === true) writeKind = "DELETE"
991
1066
  } else if (op === "query") {
992
- const sql = String(node.query || "")
1067
+ const sql = String(nodeField(node, params, "query") || "")
993
1068
  if (/\b(INSERT|UPDATE|DELETE|TRUNCATE|DROP|ALTER|CREATE)\b/i.test(sql)) {
994
1069
  writeKind = "write SQL"
995
1070
  }
@@ -1069,6 +1144,68 @@ async function verifyTablesInRemote(missingTables, projectId) {
1069
1144
  }
1070
1145
  }
1071
1146
 
1147
+ function bucketNamesFromStorageList(payload) {
1148
+ const names = new Set()
1149
+ const add = (value) => {
1150
+ if (typeof value === "string" && value.trim()) names.add(value.trim())
1151
+ }
1152
+ const candidates = []
1153
+ if (Array.isArray(payload)) {
1154
+ candidates.push(payload)
1155
+ } else if (payload && typeof payload === "object") {
1156
+ for (const key of ["buckets", "data", "items", "results"]) {
1157
+ if (Array.isArray(payload[key])) candidates.push(payload[key])
1158
+ }
1159
+ if (!candidates.length) candidates.push([payload])
1160
+ }
1161
+ for (const list of candidates) {
1162
+ for (const item of list) {
1163
+ if (typeof item === "string") {
1164
+ add(item)
1165
+ } else if (item && typeof item === "object") {
1166
+ add(item.name)
1167
+ add(item.bucket)
1168
+ add(item.bucket_name)
1169
+ }
1170
+ }
1171
+ }
1172
+ return names
1173
+ }
1174
+
1175
+ async function verifyStorageBucketsInRemote(bucketRefs, projectId) {
1176
+ if (!bucketRefs.length) return []
1177
+ const uniqueRefs = new Map(bucketRefs.map(ref => [`${ref.bucket}|${ref.endpoint}|${ref.file}|${ref.loc}`, ref]))
1178
+ const refs = [...uniqueRefs.values()]
1179
+ const bucketNames = [...new Set(refs.map(ref => ref.bucket))].sort()
1180
+ try {
1181
+ const args = { operation: "list" }
1182
+ if (projectId) args.project_id = projectId
1183
+ const result = await proxyToolCall("manage_storage", args)
1184
+ const existing = bucketNamesFromStorageList(result)
1185
+ return refs
1186
+ .filter(ref => !existing.has(ref.bucket))
1187
+ .map(ref => ({
1188
+ severity: "error",
1189
+ rule: "storage_bucket_missing",
1190
+ endpoint: ref.endpoint,
1191
+ file: ref.file,
1192
+ loc: ref.loc,
1193
+ message: `Storage bucket '${ref.bucket}' is referenced by endpoint '${ref.endpoint}', but it does not exist in this project.`,
1194
+ fix_hint:
1195
+ `Create it before testing/pushing this endpoint: ` +
1196
+ `manage_storage({ operation: "create", name: "${ref.bucket}", public: false }). ` +
1197
+ `Use public: true only for public assets; user documents/attachments should stay private.`,
1198
+ }))
1199
+ } catch (e) {
1200
+ return [{
1201
+ severity: "warn",
1202
+ rule: "storage_bucket_check_failed",
1203
+ message: `Could not verify storage buckets: ${bucketNames.join(", ")}.`,
1204
+ fix_hint: `Retry manage_storage({ operation: "list" }) or create the expected bucket before testing upload/storage endpoints. Detail: ${e.message}`,
1205
+ }]
1206
+ }
1207
+ }
1208
+
1072
1209
  /**
1073
1210
  * Refresh dypai/schema.sql from the remote. Best-effort — if it fails the
1074
1211
  * caller continues with the existing local schema (just logs the warning).
@@ -1116,6 +1253,7 @@ export async function runValidation(rootDir, projectId) {
1116
1253
  // Collected during per-endpoint pass; resolved against the remote AFTER
1117
1254
  // all endpoints are checked (one batch query instead of N).
1118
1255
  suspectedMissingTables: [],
1256
+ suspectedStorageBuckets: [],
1119
1257
  }
1120
1258
 
1121
1259
  const diagnostics = []
@@ -1185,6 +1323,7 @@ export async function runValidation(rootDir, projectId) {
1185
1323
 
1186
1324
  // Realtime YAML rules
1187
1325
  diagnostics.push(...await validateRealtime(rootDir, ctx))
1326
+ diagnostics.push(...await verifyStorageBucketsInRemote(ctx.suspectedStorageBuckets, targetProjectId))
1188
1327
 
1189
1328
  // Surface any file-read errors too
1190
1329
  for (const err of local.errors || []) {
@@ -1219,7 +1358,7 @@ export const dypaiValidateTool = {
1219
1358
  name: "dypai_validate",
1220
1359
  description:
1221
1360
  "Lint the local dypai/ folder BEFORE pushing. Catches ${input.x} / ${nodes.x.y} / ${current_user_*} refs that don't resolve, " +
1222
- "SQL tables not in schema.sql, credentials that don't exist remotely, and agent tool refs to non-tool endpoints. " +
1361
+ "SQL tables not in schema.sql, missing storage buckets, credentials that don't exist remotely, and agent tool refs to non-tool endpoints. " +
1223
1362
  "Run this after editing YAMLs to catch typos pre-flight. Push already calls it by default — pass skip_validation:true to override.",
1224
1363
  inputSchema: {
1225
1364
  type: "object",