primitive-admin 1.1.0-alpha.37 → 1.1.0-alpha.39

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.
Files changed (69) hide show
  1. package/README.md +16 -0
  2. package/dist/bin/primitive.js +2 -0
  3. package/dist/bin/primitive.js.map +1 -1
  4. package/dist/src/commands/blob-buckets.js +64 -0
  5. package/dist/src/commands/blob-buckets.js.map +1 -1
  6. package/dist/src/commands/collections.js +1 -9
  7. package/dist/src/commands/collections.js.map +1 -1
  8. package/dist/src/commands/database-types.js +11 -1
  9. package/dist/src/commands/database-types.js.map +1 -1
  10. package/dist/src/commands/databases.js +6 -0
  11. package/dist/src/commands/databases.js.map +1 -1
  12. package/dist/src/commands/group-type-configs.js +1 -9
  13. package/dist/src/commands/group-type-configs.js.map +1 -1
  14. package/dist/src/commands/guides.d.ts +75 -0
  15. package/dist/src/commands/guides.js +284 -47
  16. package/dist/src/commands/guides.js.map +1 -1
  17. package/dist/src/commands/init.js +77 -45
  18. package/dist/src/commands/init.js.map +1 -1
  19. package/dist/src/commands/rule-sets.d.ts +1 -0
  20. package/dist/src/commands/rule-sets.js +25 -3
  21. package/dist/src/commands/rule-sets.js.map +1 -1
  22. package/dist/src/commands/scripts.d.ts +2 -0
  23. package/dist/src/commands/scripts.js +667 -0
  24. package/dist/src/commands/scripts.js.map +1 -0
  25. package/dist/src/commands/sync.d.ts +52 -7
  26. package/dist/src/commands/sync.js +672 -128
  27. package/dist/src/commands/sync.js.map +1 -1
  28. package/dist/src/commands/webhooks.js +7 -2
  29. package/dist/src/commands/webhooks.js.map +1 -1
  30. package/dist/src/commands/workflows.js +235 -4
  31. package/dist/src/commands/workflows.js.map +1 -1
  32. package/dist/src/lib/api-client.d.ts +59 -16
  33. package/dist/src/lib/api-client.js +54 -2
  34. package/dist/src/lib/api-client.js.map +1 -1
  35. package/dist/src/lib/block-layout.d.ts +160 -0
  36. package/dist/src/lib/block-layout.js +451 -0
  37. package/dist/src/lib/block-layout.js.map +1 -0
  38. package/dist/src/lib/config.js +21 -0
  39. package/dist/src/lib/config.js.map +1 -1
  40. package/dist/src/lib/generated-allowlist.js +18 -0
  41. package/dist/src/lib/generated-allowlist.js.map +1 -1
  42. package/dist/src/lib/init-config.d.ts +13 -0
  43. package/dist/src/lib/init-config.js +19 -0
  44. package/dist/src/lib/init-config.js.map +1 -1
  45. package/dist/src/lib/output.d.ts +42 -5
  46. package/dist/src/lib/output.js +70 -4
  47. package/dist/src/lib/output.js.map +1 -1
  48. package/dist/src/lib/query-operators.d.ts +43 -0
  49. package/dist/src/lib/query-operators.js +80 -0
  50. package/dist/src/lib/query-operators.js.map +1 -0
  51. package/dist/src/lib/template.d.ts +9 -5
  52. package/dist/src/lib/template.js +58 -45
  53. package/dist/src/lib/template.js.map +1 -1
  54. package/dist/src/lib/toml-database-config.d.ts +20 -0
  55. package/dist/src/lib/toml-database-config.js +37 -2
  56. package/dist/src/lib/toml-database-config.js.map +1 -1
  57. package/dist/src/lib/toml-metadata-config.d.ts +108 -0
  58. package/dist/src/lib/toml-metadata-config.js +371 -0
  59. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  60. package/dist/src/lib/toml-params-validator.d.ts +34 -0
  61. package/dist/src/lib/toml-params-validator.js +118 -3
  62. package/dist/src/lib/toml-params-validator.js.map +1 -1
  63. package/dist/src/lib/workflow-apply.d.ts +66 -0
  64. package/dist/src/lib/workflow-apply.js +117 -0
  65. package/dist/src/lib/workflow-apply.js.map +1 -0
  66. package/dist/src/lib/workflow-toml-validator.js +36 -0
  67. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  68. package/dist/src/types/index.d.ts +7 -0
  69. package/package.json +1 -1
@@ -5,13 +5,16 @@ import * as TOML from "@iarna/toml";
5
5
  import { lookup as mimeLookup } from "mime-types";
6
6
  import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
7
7
  import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
8
+ import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMetadataCategoryConfig, parseDeclaredAccessManifestToml, validateDeclaredAccessManifest, serializeDeclaredAccessManifest, } from "../lib/toml-metadata-config.js";
8
9
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
9
10
  import { getServerUrl, resolveAppId } from "../lib/config.js";
10
11
  import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
11
12
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
12
13
  import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
13
14
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
14
- import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
15
+ import { applyWorkflowBody } from "../lib/workflow-apply.js";
16
+ import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
17
+ import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
15
18
  import { confirmPrompt } from "../lib/confirm-prompt.js";
16
19
  import chalk from "chalk";
17
20
  function ensureDir(dirPath) {
@@ -199,6 +202,87 @@ export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
199
202
  const parsed = TOML.parse(serialized);
200
203
  return hashWorkflowTomlForDiff(parsed);
201
204
  }
205
+ // Boolean [auth] keys in app.toml that map 1:1 to app-settings fields.
206
+ const AUTH_BOOLEAN_KEYS = [
207
+ "googleOAuthEnabled",
208
+ "passkeyEnabled",
209
+ "magicLinkEnabled",
210
+ "appleSignInEnabled",
211
+ "otpEnabled",
212
+ ];
213
+ // Every recognized [auth] key. Single source of truth shared by pull
214
+ // serialization, push parsing, and the unrecognized-key warning so the three
215
+ // never drift apart. `appleAudiences` is a string array and `passkeys` is the
216
+ // passkey RP config object; the rest are booleans.
217
+ const RECOGNIZED_AUTH_KEYS = new Set([
218
+ ...AUTH_BOOLEAN_KEYS,
219
+ "appleAudiences",
220
+ "passkeys",
221
+ ]);
222
+ /**
223
+ * Build the [auth] block of app.toml from server settings (pull direction).
224
+ *
225
+ * Apple/OTP fields round-trip alongside the legacy google/passkey/magic-link
226
+ * keys. Two asymmetries are handled deliberately:
227
+ * - `appleSignInEnabled` comes back as `boolean | null`; @iarna/toml throws on
228
+ * null, so an unset value (null) is omitted rather than written.
229
+ * - `appleAudiences` is omitted when empty. An empty array and an omitted key
230
+ * both mean "no audiences" (the server normalizes [] -> null), so omitting
231
+ * an empty list on pull is not lossy.
232
+ */
233
+ export function serializeAuthBlock(settings) {
234
+ const auth = {
235
+ googleOAuthEnabled: settings.googleOAuthEnabled,
236
+ passkeyEnabled: settings.passkeyEnabled,
237
+ magicLinkEnabled: settings.magicLinkEnabled,
238
+ };
239
+ if (typeof settings.appleSignInEnabled === "boolean") {
240
+ auth.appleSignInEnabled = settings.appleSignInEnabled;
241
+ }
242
+ if (Array.isArray(settings.appleAudiences) &&
243
+ settings.appleAudiences.length > 0) {
244
+ auth.appleAudiences = settings.appleAudiences;
245
+ }
246
+ if (typeof settings.otpEnabled === "boolean") {
247
+ auth.otpEnabled = settings.otpEnabled;
248
+ }
249
+ if (settings.passkeyRpConfig) {
250
+ auth.passkeys = settings.passkeyRpConfig;
251
+ }
252
+ return auth;
253
+ }
254
+ /**
255
+ * Translate the [auth] block of app.toml into app-settings fields (push
256
+ * direction).
257
+ *
258
+ * Only keys actually present in the TOML are forwarded — an omitted key is left
259
+ * untouched so it never overwrites server state (e.g. flipping a previously-true
260
+ * flag to false). An explicit value, including `false`, is forwarded as-is.
261
+ * `appleAudiences = []` is forwarded too; the server normalizes [] -> null
262
+ * ("no audiences"). Keys outside RECOGNIZED_AUTH_KEYS produce a warning rather
263
+ * than a hard error, so a typo or a future key doesn't silently drop.
264
+ */
265
+ export function parseAppAuthSettings(auth) {
266
+ const settings = {};
267
+ const warnings = [];
268
+ for (const key of AUTH_BOOLEAN_KEYS) {
269
+ if (key in auth)
270
+ settings[key] = auth[key];
271
+ }
272
+ if ("appleAudiences" in auth) {
273
+ settings.appleAudiences = auth.appleAudiences;
274
+ }
275
+ if ("passkeys" in auth) {
276
+ settings.passkeyRpConfig = auth.passkeys;
277
+ }
278
+ for (const key of Object.keys(auth)) {
279
+ if (!RECOGNIZED_AUTH_KEYS.has(key)) {
280
+ warnings.push(`Unrecognized [auth] key "${key}" in app.toml — ignored. ` +
281
+ `Recognized keys: ${[...RECOGNIZED_AUTH_KEYS].join(", ")}.`);
282
+ }
283
+ }
284
+ return { settings, warnings };
285
+ }
202
286
  // TOML serialization helpers
203
287
  function serializeAppSettings(settings) {
204
288
  const data = {
@@ -208,15 +292,8 @@ function serializeAppSettings(settings) {
208
292
  waitlistEnabled: settings.waitlistEnabled,
209
293
  baseUrl: settings.baseUrl,
210
294
  },
211
- auth: {
212
- googleOAuthEnabled: settings.googleOAuthEnabled,
213
- passkeyEnabled: settings.passkeyEnabled,
214
- magicLinkEnabled: settings.magicLinkEnabled,
215
- },
295
+ auth: serializeAuthBlock(settings),
216
296
  };
217
- if (settings.passkeyRpConfig) {
218
- data.auth.passkeys = settings.passkeyRpConfig;
219
- }
220
297
  if (settings.corsMode === "custom") {
221
298
  data.cors = {
222
299
  mode: settings.corsMode,
@@ -256,6 +333,15 @@ function serializeWebhook(webhook) {
256
333
  secretGracePeriodMs: webhook.secretGracePeriodMs,
257
334
  },
258
335
  };
336
+ // Re-emit a `{{secrets.KEY}}` reference so a pull→push round-trip is lossless
337
+ // (#1235). The server only returns `signingSecret` when it is such a
338
+ // reference (a real secret is returned masked, never in plaintext); guard on
339
+ // the template shape here too so a masked or raw value can never be written
340
+ // to TOML.
341
+ if (typeof webhook.signingSecret === "string" &&
342
+ /\{\{secrets\.[A-Z][A-Z0-9_]{0,63}\}\}/.test(webhook.signingSecret)) {
343
+ data.webhook.signingSecret = webhook.signingSecret;
344
+ }
259
345
  // Parse JSON fields if they're strings
260
346
  if (webhook.allowedIpCidrs) {
261
347
  try {
@@ -371,7 +457,7 @@ function serializePrompt(prompt) {
371
457
  };
372
458
  return TOML.stringify(data);
373
459
  }
374
- function serializeWorkflow(workflow, draft, configs) {
460
+ export function serializeWorkflow(workflow, draft, configs) {
375
461
  // Find the active config or use the first one
376
462
  const activeConfigId = workflow.activeConfigId;
377
463
  const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
@@ -391,6 +477,15 @@ function serializeWorkflow(workflow, draft, configs) {
391
477
  // #1081 — workflow principal mode. Emit only when set so a fresh
392
478
  // pull → push round-trips it without writing a noisy `runAs = ""`.
393
479
  runAs: workflow.runAs || undefined,
480
+ // #1233 — opt-in sensitive capability grants. The GET response always
481
+ // returns an array (empty when unset); emit only when non-empty so a
482
+ // fresh pull → push round-trips a real value without writing a noisy
483
+ // `capabilities = []`. This is the load-bearing fix: before this, pull
484
+ // dropped capabilities entirely and a pull → push cycle silently cleared
485
+ // them on the server.
486
+ capabilities: Array.isArray(workflow.capabilities) && workflow.capabilities.length > 0
487
+ ? workflow.capabilities
488
+ : undefined,
394
489
  perUserMaxRunning: workflow.perUserMaxRunning,
395
490
  perUserMaxQueued: workflow.perUserMaxQueued,
396
491
  dequeueOrder: workflow.dequeueOrder,
@@ -413,6 +508,10 @@ function serializeWorkflow(workflow, draft, configs) {
413
508
  status: config.status,
414
509
  isActive: config.configId === activeConfigId,
415
510
  })) || [],
511
+ // #1304 (P-C): the declared-access manifest — top-level `[metadata]` /
512
+ // `secrets`, merged the same way the collection/database type configs do so
513
+ // a workflow's `md`/`secrets` declarations round-trip through pull → push.
514
+ ...serializeDeclaredAccessManifest(workflow.metadataManifest),
416
515
  };
417
516
  return TOML.stringify(data);
418
517
  }
@@ -486,6 +585,9 @@ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
486
585
  collectionType: config.collectionType,
487
586
  ruleSetName: ruleSetName,
488
587
  },
588
+ // Declared-access manifest (issue #1304, P-C): merge the top-level
589
+ // `[metadata]` + `secrets` fragments so a pull→push→pull cycle round-trips.
590
+ ...serializeDeclaredAccessManifest(config.metadataManifest),
489
591
  };
490
592
  return TOML.stringify(data);
491
593
  }
@@ -530,6 +632,17 @@ export function parseDatabaseTypeToml(tomlData) {
530
632
  if (tomlData.triggers) {
531
633
  typeConfig.triggers = tomlData.triggers;
532
634
  }
635
+ // Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
636
+ // `secrets` blocks. Validated locally before push; the server re-validates
637
+ // (graph/schema) at persist time.
638
+ const manifest = parseDeclaredAccessManifestToml(tomlData);
639
+ if (manifest) {
640
+ const manifestError = validateDeclaredAccessManifest(manifest);
641
+ if (manifestError) {
642
+ throw new Error(`Invalid metadata manifest in database type "${typeSection.databaseType || ""}": ${manifestError}`);
643
+ }
644
+ typeConfig.metadataManifest = manifest;
645
+ }
533
646
  // Issue #666: extract the `[models.*]` subtree (if present) and re-serialize
534
647
  // it to a TOML string for the server's `schema` field.
535
648
  //
@@ -598,6 +711,17 @@ export function parseCollectionTypeConfigToml(tomlData) {
598
711
  else if (section.ruleSetId && section.ruleSetId !== "") {
599
712
  result.ruleSetId = section.ruleSetId;
600
713
  }
714
+ // Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
715
+ // `secrets` blocks. Validated locally before push; the server re-validates
716
+ // (graph/schema) at persist time.
717
+ const manifest = parseDeclaredAccessManifestToml(tomlData);
718
+ if (manifest) {
719
+ const manifestError = validateDeclaredAccessManifest(manifest);
720
+ if (manifestError) {
721
+ throw new Error(`Invalid metadata manifest in collection type config "${section.collectionType || ""}": ${manifestError}`);
722
+ }
723
+ result.metadataManifest = manifest;
724
+ }
601
725
  return result;
602
726
  }
603
727
  // Parsing helpers
@@ -681,7 +805,14 @@ export function resolveSlugCollisions(slug, usedSlugs) {
681
805
  return resolved;
682
806
  }
683
807
  function getTestsDir(configDir, blockType, blockKey) {
684
- const typeDir = blockType === "prompt" ? "prompts" : "workflows";
808
+ // Scripts (blockType "script") live under `transforms/` on disk (their
809
+ // bodies are `transforms/<name>.rhai`), so their test cases sit alongside at
810
+ // `transforms/<name>.tests/`. Prompts and workflows keep their own dirs.
811
+ const typeDir = blockType === "prompt"
812
+ ? "prompts"
813
+ : blockType === "script"
814
+ ? "transforms"
815
+ : "workflows";
685
816
  return join(configDir, typeDir, `${blockKey}.tests`);
686
817
  }
687
818
  export function serializeTestCase(testCase, lookupMaps) {
@@ -771,17 +902,33 @@ export function parseTestCaseToml(tomlData) {
771
902
  /**
772
903
  * Pull server-side `Script` rows into `transforms/*.rhai` and record
773
904
  * each in the returned sync-state map. Issue #892 slice 7 + codex
774
- * follow-up on PR #893. Exported so the unit test can drive it
775
- * against a stubbed `client.listScripts`.
905
+ * follow-up on PR #893; body materialization fixed in issue #1196.
906
+ * Exported so the unit test can drive it against stubbed
907
+ * `client.listScripts` / `client.getScript`.
908
+ *
909
+ * Two-call contract: `listScripts` returns the `Script` HEADER only
910
+ * (no `body` — see `serializeScript` in `src/admin-api.ts`). The Rhai
911
+ * source lives on a versioned `ScriptConfig`. So for each listed
912
+ * script we fetch the full record with `getScript`, which returns the
913
+ * header plus serialized `configs` (each carrying its `body`), and
914
+ * write the ACTIVE config's body. Reading the body off the list shape
915
+ * (the old behavior) always produced 0-byte files.
776
916
  *
777
- * Idempotency: every call writes the body the server returned, so
778
- * re-running `sync pull` on an unchanged server overwrites with the
779
- * same bytes and produces the same `contentHash`. The result map
780
- * always reflects the current server state for the writes performed.
917
+ * Idempotency: every call writes the active-config body the server
918
+ * returned, so re-running `sync pull` on an unchanged server overwrites
919
+ * with the same bytes and produces the same `contentHash`. The result
920
+ * map always reflects the current server state for the writes performed.
921
+ *
922
+ * `count` is the number of files actually written, not the number of
923
+ * scripts listed: scripts with no active config and scripts whose
924
+ * config fetch fails are skipped (we never clobber a `.rhai` with an
925
+ * empty file).
781
926
  *
782
927
  * Older-server graceful path: if `listScripts` rejects (e.g. older
783
928
  * server without the route), the caller catches and treats it as
784
- * "no scripts to pull", leaving the directory empty.
929
+ * "no scripts to pull", leaving the directory empty. A per-script
930
+ * `getScript` failure is caught and that one script is skipped without
931
+ * aborting the rest of the pull.
785
932
  */
786
933
  export async function pullScripts(client, appId, configDir, logger = () => { }) {
787
934
  const transformsDir = join(configDir, "transforms");
@@ -795,22 +942,49 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
795
942
  catch {
796
943
  return { scriptEntities, count: 0 };
797
944
  }
945
+ let written = 0;
798
946
  for (const script of items) {
799
947
  const name = script?.name;
800
948
  if (!name)
801
949
  continue;
950
+ // The body lives on the active `ScriptConfig`, not the header. Skip
951
+ // scripts with no active config rather than writing an empty file.
952
+ if (!script.activeConfigId) {
953
+ logger(` Skipped transforms/${name}.rhai (no active config)`);
954
+ continue;
955
+ }
956
+ // Fetch the full script (header + serialized configs with bodies).
957
+ // Tolerate a per-script failure: skip this one, keep the rest.
958
+ let full;
959
+ try {
960
+ full = await client.getScript(appId, script.scriptId);
961
+ }
962
+ catch {
963
+ logger(` Skipped transforms/${name}.rhai (could not fetch config)`);
964
+ continue;
965
+ }
966
+ const configs = Array.isArray(full?.configs) ? full.configs : [];
967
+ const activeConfigId = full?.activeConfigId ?? script.activeConfigId;
968
+ const activeConfig = configs.find((c) => c.configId === activeConfigId) ||
969
+ configs.find((c) => c.status === "active");
970
+ if (!activeConfig || typeof activeConfig.body !== "string") {
971
+ logger(` Skipped transforms/${name}.rhai (no active config body)`);
972
+ continue;
973
+ }
802
974
  const filename = `${name}.rhai`;
803
975
  const filePath = join(transformsDir, filename);
804
- const body = typeof script.body === "string" ? script.body : "";
805
- writeFileSync(filePath, body);
976
+ writeFileSync(filePath, activeConfig.body);
806
977
  scriptEntities[name] = {
807
978
  id: script.scriptId,
808
979
  modifiedAt: script.modifiedAt || new Date().toISOString(),
980
+ // Recompute from the written file (not the server's contentHash) so
981
+ // it matches what `sync push`'s `shouldPushFile` gate recomputes.
809
982
  contentHash: computeFileHash(filePath),
810
983
  };
984
+ written += 1;
811
985
  logger(` Wrote transforms/${filename}`);
812
986
  }
813
- return { scriptEntities, count: items.length };
987
+ return { scriptEntities, count: written };
814
988
  }
815
989
  async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
816
990
  let testCases;
@@ -920,8 +1094,10 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
920
1094
  const configMapKey = `${blockKey}#${testPayload._configName}`;
921
1095
  const configMap = blockType === "prompt"
922
1096
  ? resolutionMaps.promptConfigNameToId
923
- : resolutionMaps.workflowConfigNameToId;
924
- const resolvedId = configMap.get(configMapKey);
1097
+ : blockType === "script"
1098
+ ? resolutionMaps.scriptConfigNameToId
1099
+ : resolutionMaps.workflowConfigNameToId;
1100
+ const resolvedId = configMap?.get(configMapKey);
925
1101
  if (resolvedId) {
926
1102
  testPayload.configId = resolvedId;
927
1103
  }
@@ -1123,6 +1299,7 @@ Directory Structure:
1123
1299
  rule-sets/*.toml # Access rule sets
1124
1300
  group-type-configs/*.toml # Group type configs
1125
1301
  collection-type-configs/*.toml # Collection type configs
1302
+ metadata-category-configs/*.toml # Resource-metadata category configs
1126
1303
  email-templates/*.toml # Email template overrides
1127
1304
  `);
1128
1305
  // Init
@@ -1153,6 +1330,7 @@ Directory Structure:
1153
1330
  ensureDir(join(configDir, "rule-sets"));
1154
1331
  ensureDir(join(configDir, "group-type-configs"));
1155
1332
  ensureDir(join(configDir, "collection-type-configs"));
1333
+ ensureDir(join(configDir, "metadata-category-configs"));
1156
1334
  ensureDir(join(configDir, "email-templates"));
1157
1335
  const state = {
1158
1336
  appId: resolvedAppId,
@@ -1224,11 +1402,12 @@ Directory Structure:
1224
1402
  return;
1225
1403
  }
1226
1404
  // Fetch database config resources
1227
- const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult,] = await Promise.all([
1405
+ const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult, metadataCategoryConfigsResult,] = await Promise.all([
1228
1406
  client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
1229
1407
  client.listRuleSets(resolvedAppId).catch(() => []),
1230
1408
  client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
1231
1409
  client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
1410
+ client.listMetadataCategoryConfigs(resolvedAppId).catch(() => []),
1232
1411
  ]);
1233
1412
  // Fetch operations + subscriptions for each database type. Issue #803:
1234
1413
  // subscriptions are pulled symmetrically with operations so a
@@ -1287,6 +1466,7 @@ Directory Structure:
1287
1466
  ensureDir(join(configDir, "rule-sets"));
1288
1467
  ensureDir(join(configDir, "group-type-configs"));
1289
1468
  ensureDir(join(configDir, "collection-type-configs"));
1469
+ ensureDir(join(configDir, "metadata-category-configs"));
1290
1470
  ensureDir(join(configDir, "email-templates"));
1291
1471
  // Write app settings
1292
1472
  if (settings) {
@@ -1425,6 +1605,23 @@ Directory Structure:
1425
1605
  }
1426
1606
  }
1427
1607
  }
1608
+ // Include script config names so a pinned script test case
1609
+ // round-trips its `configName` reference (the same way prompt/workflow
1610
+ // configs above do). One extra `configs` fetch per pulled script.
1611
+ for (const [name, entity] of Object.entries(scriptEntities)) {
1612
+ try {
1613
+ const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, entity.id);
1614
+ for (const config of scriptConfigs) {
1615
+ configIdToName.set(config.configId, config.configName);
1616
+ }
1617
+ }
1618
+ catch {
1619
+ // Non-fatal: a script test case pinned to a config just serializes
1620
+ // without a resolvable name (falls back to active on push). The
1621
+ // header/body still pulled fine — `name` retained for clarity.
1622
+ void name;
1623
+ }
1624
+ }
1428
1625
  const testCaseLookupMaps = { configIdToName, promptIdToKey };
1429
1626
  // Write email templates (use detailed emailTemplates, not summaries)
1430
1627
  const emailTemplateEntities = {};
@@ -1467,6 +1664,15 @@ Directory Structure:
1467
1664
  }
1468
1665
  totalTestCases += count;
1469
1666
  }
1667
+ // Pull test cases for scripts (transforms). Script blocks use
1668
+ // blockType "script"; their tests land under transforms/<name>.tests/.
1669
+ for (const [name, entity] of Object.entries(scriptEntities)) {
1670
+ const count = await pullTestCasesForBlock(client, resolvedAppId, "script", entity.id, name, configDir, testCaseEntities, testCaseLookupMaps);
1671
+ if (count > 0) {
1672
+ info(` Wrote ${count} test case(s) for script: ${name}`);
1673
+ }
1674
+ totalTestCases += count;
1675
+ }
1470
1676
  // Build ruleSetId → name map for database types and group type configs
1471
1677
  const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
1472
1678
  const ruleSetIdToName = new Map();
@@ -1562,6 +1768,23 @@ Directory Structure:
1562
1768
  };
1563
1769
  info(` Wrote collection-type-configs/${filename}`);
1564
1770
  }
1771
+ // Write metadata category configs (issue #1304, P-B). One file per
1772
+ // `{resourceType, category}`; the entity key is the `resourceType#category`
1773
+ // pair so push can match it back.
1774
+ const metadataCategoryConfigEntities = {};
1775
+ const metadataCategoryConfigs = Array.isArray(metadataCategoryConfigsResult)
1776
+ ? metadataCategoryConfigsResult
1777
+ : [];
1778
+ for (const config of metadataCategoryConfigs) {
1779
+ const filename = `${config.resourceType}.${config.category}.toml`;
1780
+ const filePath = join(configDir, "metadata-category-configs", filename);
1781
+ writeFileSync(filePath, serializeMetadataCategoryConfig(config));
1782
+ metadataCategoryConfigEntities[`${config.resourceType}#${config.category}`] = {
1783
+ modifiedAt: config.modifiedAt || new Date().toISOString(),
1784
+ contentHash: computeFileHash(filePath),
1785
+ };
1786
+ info(` Wrote metadata-category-configs/${filename}`);
1787
+ }
1565
1788
  // Save sync state
1566
1789
  const state = {
1567
1790
  appId: resolvedAppId,
@@ -1584,6 +1807,9 @@ Directory Structure:
1584
1807
  collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
1585
1808
  ? collectionTypeConfigEntities
1586
1809
  : undefined,
1810
+ metadataCategoryConfigs: Object.keys(metadataCategoryConfigEntities).length > 0
1811
+ ? metadataCategoryConfigEntities
1812
+ : undefined,
1587
1813
  },
1588
1814
  };
1589
1815
  saveSyncState(configDir, state);
@@ -1602,6 +1828,7 @@ Directory Structure:
1602
1828
  keyValue("Rule Sets", ruleSets.length);
1603
1829
  keyValue("Group Type Configs", groupTypeConfigs.length);
1604
1830
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
1831
+ keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
1605
1832
  }
1606
1833
  catch (err) {
1607
1834
  error(err.message);
@@ -1877,11 +2104,10 @@ Directory Structure:
1877
2104
  }
1878
2105
  }
1879
2106
  if (tomlData.auth) {
1880
- settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
1881
- settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
1882
- settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
1883
- if (tomlData.auth.passkeys) {
1884
- settings.passkeyRpConfig = tomlData.auth.passkeys;
2107
+ const { settings: authSettings, warnings: authWarnings } = parseAppAuthSettings(tomlData.auth);
2108
+ Object.assign(settings, authSettings);
2109
+ for (const w of authWarnings) {
2110
+ warn(` ${w}`);
1885
2111
  }
1886
2112
  }
1887
2113
  if (tomlData.cors) {
@@ -2099,6 +2325,14 @@ Directory Structure:
2099
2325
  deduplicationWindowMs: webhook.deduplicationWindowMs,
2100
2326
  secretGracePeriodMs: webhook.secretGracePeriodMs,
2101
2327
  };
2328
+ // Forward the declared signing secret so a `{{secrets.KEY}}`
2329
+ // reference reaches the server intact (#1235). Without this the
2330
+ // value declared in TOML is silently dropped and the server
2331
+ // rejects a verified webhook with "signingSecret is required".
2332
+ // Mirrors how integrations forward requestConfig with refs intact.
2333
+ if (webhook.signingSecret !== undefined) {
2334
+ payload.signingSecret = webhook.signingSecret;
2335
+ }
2102
2336
  // Handle JSON fields
2103
2337
  if (tomlData.allowedIps?.cidrs) {
2104
2338
  payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
@@ -2731,6 +2965,19 @@ Directory Structure:
2731
2965
  const workflow = tomlData.workflow || {};
2732
2966
  const key = workflow.key || basename(file, ".toml");
2733
2967
  const steps = tomlData.steps || [];
2968
+ // #1304 (P-C): the declared-access manifest lives at top-level
2969
+ // `[metadata]` / `secrets` (same shape the database/collection type
2970
+ // configs use). Parse + surface-validate it, then carry it on the
2971
+ // `workflow` payload object so it flows through both the create call
2972
+ // and the shared `applyWorkflowBody` update. A null manifest (no
2973
+ // `[metadata]`) clears it on the server so pull → push round-trips a
2974
+ // drop; the deep graph check runs server-side at persist.
2975
+ const workflowManifest = parseDeclaredAccessManifestToml(tomlData);
2976
+ const workflowManifestError = validateDeclaredAccessManifest(workflowManifest);
2977
+ if (workflowManifestError) {
2978
+ throw new Error(`${filePath}: ${workflowManifestError}`);
2979
+ }
2980
+ workflow.metadataManifest = workflowManifest;
2734
2981
  const existingId = syncState?.entities?.workflows?.[key]?.id;
2735
2982
  const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
2736
2983
  // Skip if file hasn't changed since last sync. Use the expanded
@@ -2751,6 +2998,45 @@ Directory Structure:
2751
2998
  }
2752
2999
  continue;
2753
3000
  }
3001
+ // Shared update body for both the in-manifest update branch and the
3002
+ // adopt-by-key recovery branch (#1174). The body is large and
3003
+ // ordering-sensitive (metadata PATCH → config/draft steps →
3004
+ // deferred `syncCallable` PATCH); both call sites MUST run it
3005
+ // identically, so it lives here as a single closure rather than
3006
+ // being duplicated. `activeConfigId` is passed in explicitly: the
3007
+ // update branch reads it from sync state, while the adopt branch
3008
+ // seeds it from a fresh `getWorkflow` (sync state has none for an
3009
+ // out-of-manifest workflow).
3010
+ const applyWorkflowUpdate = async (workflowId, opts) => {
3011
+ const { expectedModifiedAt, activeConfigId } = opts;
3012
+ // The ordering-sensitive core (metadata PATCH → config/draft
3013
+ // steps → deferred `syncCallable` PATCH, #807) lives in the
3014
+ // shared `applyWorkflowBody` helper so `workflows update
3015
+ // --from-file` runs it identically (#1249, Fork 4). The
3016
+ // sync-specific bookkeeping (the progress log, sync-state
3017
+ // `modifiedAt`/hash, and config name→ID map) stays here.
3018
+ const { latestModifiedAt, updateSlotLabel, fullWorkflow } = await applyWorkflowBody(client, resolvedAppId, workflowId, workflow, steps, {
3019
+ expectedModifiedAt,
3020
+ activeConfigId,
3021
+ force: options.force,
3022
+ });
3023
+ info(` Updated workflow: ${key} (${updateSlotLabel})`);
3024
+ // Update sync state with new modifiedAt. Store the *expanded*
3025
+ // content hash so future fragment-only edits are detected. The
3026
+ // adopt branch seeds the entity record before calling this, so
3027
+ // the entry exists for both call sites.
3028
+ if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
3029
+ syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
3030
+ syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
3031
+ }
3032
+ // `applyWorkflowBody` already fetched the full workflow; reuse it
3033
+ // for the config name→ID mappings (the PATCH response omits configs).
3034
+ if (fullWorkflow?.configs) {
3035
+ for (const config of fullWorkflow.configs) {
3036
+ workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
3037
+ }
3038
+ }
3039
+ };
2754
3040
  if (existingId) {
2755
3041
  // Update existing workflow
2756
3042
  changes.push({ type: "workflow", action: "update", key });
@@ -2759,95 +3045,10 @@ Directory Structure:
2759
3045
  ? undefined
2760
3046
  : syncState?.entities?.workflows?.[key]?.modifiedAt;
2761
3047
  try {
2762
- // Update workflow metadata and schemas.
2763
- //
2764
- // Sync-callable ordering (#807, codex review): the metadata
2765
- // PATCH must NOT carry `syncCallable: true` here. The server
2766
- // re-validates `syncCallable: true` against the workflow's
2767
- // CURRENTLY-active server steps (`loadCurrentActiveSteps`),
2768
- // not the steps being pushed in the same sync. So when a
2769
- // single TOML edit both enables `syncCallable` and replaces
2770
- // sync-incompatible active steps with compatible ones, a
2771
- // combined metadata-first PATCH would be rejected against the
2772
- // stale steps. Defer `syncCallable` to a second PATCH issued
2773
- // AFTER the config/step update lands, so it validates against
2774
- // the new (compatible) active steps. `requiresClientApply`
2775
- // has no step-dependent validation, so it stays in the first
2776
- // PATCH unchanged.
2777
- const updated = await client.updateWorkflow(resolvedAppId, existingId, {
2778
- name: workflow.name,
2779
- description: workflow.description,
2780
- status: workflow.status,
2781
- accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
2782
- // #1081 — workflow principal mode. Absent key → undefined
2783
- // (server leaves it untouched); explicit value/empty is
2784
- // forwarded so it round-trips.
2785
- runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
2786
- perUserMaxRunning: workflow.perUserMaxRunning,
2787
- perUserMaxQueued: workflow.perUserMaxQueued,
2788
- dequeueOrder: workflow.dequeueOrder,
2789
- // Sync-callable flags (#807): pass through as-is. An absent
2790
- // TOML key is `undefined` (dropped by JSON.stringify, so the
2791
- // server's hasOwnProperty guard leaves the value untouched);
2792
- // an explicit `false` is preserved as a meaningful value.
2793
- requiresClientApply: workflow.requiresClientApply,
2794
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2795
- outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
2796
- }, expectedModifiedAt);
2797
- // Track the latest workflow `modifiedAt` for the sync-state
2798
- // write below. The first PATCH bumps it; the `syncCallable`
2799
- // PATCH (if any) bumps it again. The config/step update does
2800
- // NOT touch the workflow definition's `modifiedAt`.
2801
- let latestModifiedAt = updated?.workflow?.modifiedAt;
2802
- // Update active configuration steps (or draft for legacy).
2803
- // Issue #687: name the slot we touched so the dev-loop
2804
- // user can confirm before previewing.
2805
- let updateSlotLabel = "active config";
2806
- if (existingActiveConfigId) {
2807
- await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
2808
- steps,
2809
- });
2810
- }
2811
- else {
2812
- // Fallback to draft update for legacy workflows
2813
- await client.updateWorkflowDraft(resolvedAppId, existingId, {
2814
- steps,
2815
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2816
- });
2817
- updateSlotLabel = "draft (legacy)";
2818
- }
2819
- // Second PATCH: apply `syncCallable` now that the new steps
2820
- // are active (#807). Only sent when the TOML actually carries
2821
- // the key — an absent key stays `undefined` and we skip the
2822
- // call entirely, preserving the no-clobber discipline. Chain
2823
- // `expectedModifiedAt` off the first PATCH's returned value so
2824
- // optimistic concurrency stays intact (the step update above
2825
- // doesn't change the workflow definition's `modifiedAt`).
2826
- if (workflow.syncCallable !== undefined) {
2827
- const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, existingId, { syncCallable: workflow.syncCallable },
2828
- // Mirror the first PATCH's concurrency posture: `--force`
2829
- // skips the check (undefined), otherwise reuse the fresh
2830
- // `modifiedAt` from the first PATCH.
2831
- options.force ? undefined : latestModifiedAt);
2832
- if (syncCallableUpdated?.workflow?.modifiedAt) {
2833
- latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
2834
- }
2835
- }
2836
- info(` Updated workflow: ${key} (${updateSlotLabel})`);
2837
- // Update sync state with new modifiedAt. Store the *expanded*
2838
- // content hash so future fragment-only edits are detected.
2839
- if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
2840
- syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
2841
- syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
2842
- }
2843
- // Fetch full workflow to get config name→ID mappings
2844
- // (updateWorkflow response doesn't include configs)
2845
- const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
2846
- if (fullWorkflow?.configs) {
2847
- for (const config of fullWorkflow.configs) {
2848
- workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
2849
- }
2850
- }
3048
+ await applyWorkflowUpdate(existingId, {
3049
+ expectedModifiedAt,
3050
+ activeConfigId: existingActiveConfigId,
3051
+ });
2851
3052
  }
2852
3053
  catch (err) {
2853
3054
  if (err instanceof ConflictError) {
@@ -2888,6 +3089,9 @@ Directory Structure:
2888
3089
  // syncCallable=false); an explicit value is honored.
2889
3090
  requiresClientApply: workflow.requiresClientApply,
2890
3091
  syncCallable: workflow.syncCallable,
3092
+ // #1304 (P-C): declared-access manifest (absent → undefined,
3093
+ // server default null).
3094
+ metadataManifest: workflow.metadataManifest ?? undefined,
2891
3095
  });
2892
3096
  info(` Created workflow: ${key}`);
2893
3097
  // Add new entity to sync state (including activeConfigId)
@@ -2936,7 +3140,116 @@ Directory Structure:
2936
3140
  }
2937
3141
  }
2938
3142
  catch (err) {
2939
- throw wrapEntityError(err, "create", "workflow", key);
3143
+ // Issue #1174: idempotent create adopt-by-key on conflict.
3144
+ // A workflow can exist on the server but be absent from local
3145
+ // sync state — e.g. created out-of-band via the admin API
3146
+ // (#971), or orphaned by a prior push that aborted before
3147
+ // recording it. On retry this CREATE path hits the
3148
+ // `workflowKeyPerApp` unique constraint and the server
3149
+ // returns "workflowKey already exists" (HTTP 400; cron uses
3150
+ // 409). Rather than abort the whole push, look up the
3151
+ // existing workflow by key (the list endpoint is app-scoped,
3152
+ // so every item is owned by this app), verify the SAME key,
3153
+ // adopt its id into sync state, and re-issue as an UPDATE so
3154
+ // the push converges. Mirrors the cron-trigger recovery at
3155
+ // ~sync.ts:2627. Unexpected errors still surface via
3156
+ // `wrapEntityError`, matching the db-type precedent.
3157
+ const msg = String(err?.message || err);
3158
+ const isConflict = err?.statusCode === 409 || msg.includes("already exists");
3159
+ if (!isConflict) {
3160
+ throw wrapEntityError(err, "create", "workflow", key);
3161
+ }
3162
+ info(` Workflow already exists on server, adopting by key: ${key}`);
3163
+ let adoptedId;
3164
+ try {
3165
+ // Use fetchAll — listWorkflows is paginated, so a single
3166
+ // page could miss the by-key match on a large app.
3167
+ const items = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
3168
+ // Verify same key + app ownership: the list endpoint only
3169
+ // returns workflows for `resolvedAppId`, so a workflowKey
3170
+ // match is also an ownership match. Never overwrite an
3171
+ // unrelated resource — require exact key equality.
3172
+ const existing = (items || []).find((w) => w?.workflowKey === key);
3173
+ if (existing?.workflowId) {
3174
+ adoptedId = existing.workflowId;
3175
+ }
3176
+ }
3177
+ catch (lookupErr) {
3178
+ throw wrapEntityError(new Error(`workflow "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "workflow", key);
3179
+ }
3180
+ if (!adoptedId) {
3181
+ // "already exists" but no matching key found on the server —
3182
+ // surface the original error rather than silently
3183
+ // overwriting an unrelated workflow.
3184
+ throw wrapEntityError(err, "create", "workflow", key);
3185
+ }
3186
+ // Out-of-manifest adopt: warn (info-level), matching the cron
3187
+ // adopt line, and seed the sync-state entity record so the
3188
+ // shared update body can write modifiedAt/contentHash into it.
3189
+ if (syncState) {
3190
+ if (!syncState.entities.workflows) {
3191
+ syncState.entities.workflows = {};
3192
+ }
3193
+ if (!syncState.entities.workflows[key]) {
3194
+ syncState.entities.workflows[key] = {
3195
+ id: adoptedId,
3196
+ };
3197
+ }
3198
+ else {
3199
+ syncState.entities.workflows[key].id = adoptedId;
3200
+ }
3201
+ }
3202
+ // Seed the real activeConfigId from the server before the
3203
+ // shared update body runs. Sync state has none for an
3204
+ // out-of-manifest workflow, so without this the shared body
3205
+ // would wrongly fall back to `updateWorkflowDraft`.
3206
+ let adoptedActiveConfigId;
3207
+ try {
3208
+ const fullWorkflow = await client.getWorkflow(resolvedAppId, adoptedId);
3209
+ adoptedActiveConfigId =
3210
+ fullWorkflow?.workflow?.activeConfigId;
3211
+ }
3212
+ catch (getErr) {
3213
+ throw wrapEntityError(new Error(`workflow "${key}" was adopted but could not be loaded (getWorkflow failed: ${String(getErr?.message || getErr)})`), "update", "workflow", key);
3214
+ }
3215
+ // Persist the fetched activeConfigId into the seeded
3216
+ // sync-state record so it round-trips identically to the
3217
+ // CREATE path (~sync.ts:3387). Without this the *next*
3218
+ // content-changing push reads `existingActiveConfigId ===
3219
+ // undefined` (sync.ts:3187) and wrongly falls back to the
3220
+ // legacy `updateWorkflowDraft` instead of
3221
+ // `updateWorkflowConfig` — a persisted-state divergence from
3222
+ // a normally-created workflow.
3223
+ if (syncState?.entities?.workflows?.[key] &&
3224
+ adoptedActiveConfigId) {
3225
+ syncState.entities.workflows[key].activeConfigId =
3226
+ adoptedActiveConfigId;
3227
+ }
3228
+ // Switch to UPDATE on the adopted workflow. First adopt has no
3229
+ // stored modifiedAt → omit expectedModifiedAt (force the
3230
+ // PATCH), matching the cron adopt + the --force path. An
3231
+ // unexpected ConflictError here is still adoptable resilience
3232
+ // scope, so collect it; any other error surfaces.
3233
+ try {
3234
+ await applyWorkflowUpdate(adoptedId, {
3235
+ expectedModifiedAt: undefined,
3236
+ activeConfigId: adoptedActiveConfigId,
3237
+ });
3238
+ info(` Adopted + updated workflow: ${key}`);
3239
+ }
3240
+ catch (updErr) {
3241
+ if (updErr instanceof ConflictError) {
3242
+ conflicts.push({
3243
+ type: "workflow",
3244
+ key,
3245
+ serverModifiedAt: updErr.serverModifiedAt,
3246
+ localModifiedAt: "unknown",
3247
+ });
3248
+ }
3249
+ else {
3250
+ throw wrapEntityError(updErr, "update", "workflow", key);
3251
+ }
3252
+ }
2940
3253
  }
2941
3254
  }
2942
3255
  }
@@ -3044,6 +3357,14 @@ Directory Structure:
3044
3357
  if ("timestamps" in typeConfig) {
3045
3358
  u.timestamps = typeConfig.timestamps || null;
3046
3359
  }
3360
+ // Issue #1304 (P-C): declared-access manifest. `parseDatabaseTypeToml`
3361
+ // only sets `metadataManifest` when the TOML declares a `[metadata]`
3362
+ // / `secrets` block, so this forwards a set/update; clearing a
3363
+ // manifest by removing the block is a follow-on (mirrors the
3364
+ // schema-deletion tracking).
3365
+ if ("metadataManifest" in typeConfig) {
3366
+ u.metadataManifest = typeConfig.metadataManifest || null;
3367
+ }
3047
3368
  const localHasSchema = typeof typeConfig.schema === "string" &&
3048
3369
  typeConfig.schema.trim().length > 0;
3049
3370
  if (localHasSchema) {
@@ -3413,7 +3734,8 @@ Directory Structure:
3413
3734
  "defaultAccess" in typeConfig ||
3414
3735
  "autoPopulatedFields" in typeConfig ||
3415
3736
  "timestamps" in typeConfig ||
3416
- "schema" in typeConfig;
3737
+ "schema" in typeConfig ||
3738
+ "metadataManifest" in typeConfig;
3417
3739
  if (wouldUpdate) {
3418
3740
  changes.push({ type: "database-type", action: "update", key: dbType });
3419
3741
  }
@@ -3446,6 +3768,9 @@ Directory Structure:
3446
3768
  // validating against the intended shape.
3447
3769
  if (typeConfig.schema)
3448
3770
  createData.schema = typeConfig.schema;
3771
+ // Issue #1304 (P-C): declared-access manifest.
3772
+ if (typeConfig.metadataManifest)
3773
+ createData.metadataManifest = typeConfig.metadataManifest;
3449
3774
  try {
3450
3775
  const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
3451
3776
  info(` Created database type: ${dbType}`);
@@ -3979,6 +4304,7 @@ Directory Structure:
3979
4304
  try {
3980
4305
  const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
3981
4306
  ruleSetId: configData.ruleSetId ?? null,
4307
+ metadataManifest: configData.metadataManifest ?? null,
3982
4308
  }, expectedModifiedAt);
3983
4309
  info(` Updated collection type config: ${collectionType}`);
3984
4310
  if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
@@ -4009,6 +4335,9 @@ Directory Structure:
4009
4335
  const created = await client.createCollectionTypeConfig(resolvedAppId, {
4010
4336
  collectionType,
4011
4337
  ruleSetId: configData.ruleSetId || undefined,
4338
+ ...(configData.metadataManifest
4339
+ ? { metadataManifest: configData.metadataManifest }
4340
+ : {}),
4012
4341
  });
4013
4342
  info(` Created collection type config: ${collectionType}`);
4014
4343
  if (syncState) {
@@ -4028,6 +4357,66 @@ Directory Structure:
4028
4357
  }
4029
4358
  }
4030
4359
  }
4360
+ // Process metadata category configs (issue #1304, P-B). Idempotent
4361
+ // upsert via the path-addressed PUT route — no create/update split.
4362
+ const metadataCategoryConfigsDir = join(configDir, "metadata-category-configs");
4363
+ if (existsSync(metadataCategoryConfigsDir)) {
4364
+ const files = readdirSync(metadataCategoryConfigsDir).filter((f) => f.endsWith(".toml"));
4365
+ for (const file of files) {
4366
+ const filePath = join(metadataCategoryConfigsDir, file);
4367
+ const tomlData = parseTomlFile(filePath);
4368
+ const configData = parseMetadataCategoryToml(tomlData);
4369
+ // Fall back to the filename (`resourceType.category.toml`) if the
4370
+ // section omits the identity fields.
4371
+ if (!configData.resourceType || !configData.category) {
4372
+ const base = basename(file, ".toml");
4373
+ const dot = base.indexOf(".");
4374
+ if (dot > 0) {
4375
+ configData.resourceType = configData.resourceType || base.slice(0, dot);
4376
+ configData.category = configData.category || base.slice(dot + 1);
4377
+ }
4378
+ }
4379
+ const validationError = validateMetadataCategoryConfig(configData);
4380
+ if (validationError) {
4381
+ throw new Error(`Invalid metadata category config in ${file}: ${validationError}`);
4382
+ }
4383
+ const entityKey = `${configData.resourceType}#${configData.category}`;
4384
+ const existingEntry = syncState?.entities?.metadataCategoryConfigs?.[entityKey];
4385
+ // Skip if the file hasn't changed since last sync.
4386
+ if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
4387
+ skippedCount++;
4388
+ continue;
4389
+ }
4390
+ changes.push({
4391
+ type: "metadata-category-config",
4392
+ action: existingEntry ? "update" : "create",
4393
+ key: entityKey,
4394
+ });
4395
+ if (!options.dryRun) {
4396
+ try {
4397
+ const saved = await client.upsertMetadataCategoryConfig(resolvedAppId, configData.resourceType, configData.category, {
4398
+ schema: configData.schema,
4399
+ readRule: configData.readRule,
4400
+ writeRule: configData.writeRule,
4401
+ description: configData.description,
4402
+ });
4403
+ info(` Upserted metadata category config: ${entityKey}`);
4404
+ if (syncState) {
4405
+ if (!syncState.entities.metadataCategoryConfigs) {
4406
+ syncState.entities.metadataCategoryConfigs = {};
4407
+ }
4408
+ syncState.entities.metadataCategoryConfigs[entityKey] = {
4409
+ modifiedAt: saved?.modifiedAt || new Date().toISOString(),
4410
+ contentHash: computeFileHash(filePath),
4411
+ };
4412
+ }
4413
+ }
4414
+ catch (err) {
4415
+ throw wrapEntityError(err, existingEntry ? "update" : "create", "metadata category config", entityKey);
4416
+ }
4417
+ }
4418
+ }
4419
+ }
4031
4420
  // Process email templates
4032
4421
  const emailTemplatesDir = join(configDir, "email-templates");
4033
4422
  if (existsSync(emailTemplatesDir)) {
@@ -4064,11 +4453,13 @@ Directory Structure:
4064
4453
  }
4065
4454
  }
4066
4455
  }
4067
- // Push test cases for prompts and workflows
4456
+ // Push test cases for prompts, workflows, and scripts
4457
+ const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
4068
4458
  const pushMaps = {
4069
4459
  promptKeyToId,
4070
4460
  promptConfigNameToId,
4071
4461
  workflowConfigNameToId,
4462
+ scriptConfigNameToId,
4072
4463
  };
4073
4464
  const promptsDir2 = join(configDir, "prompts");
4074
4465
  if (existsSync(promptsDir2)) {
@@ -4092,6 +4483,33 @@ Directory Structure:
4092
4483
  }
4093
4484
  }
4094
4485
  }
4486
+ // Push test cases for scripts (transforms). Each script's tests live at
4487
+ // transforms/<name>.tests/; the scriptId was recorded in
4488
+ // entities.scripts[name].id when the script body was pushed above.
4489
+ const transformsDir2 = join(configDir, "transforms");
4490
+ if (existsSync(transformsDir2)) {
4491
+ for (const file of readdirSync(transformsDir2).filter((f) => f.endsWith(".rhai"))) {
4492
+ const scriptName = basename(file, ".rhai");
4493
+ const blockId = syncState?.entities?.scripts?.[scriptName]?.id;
4494
+ if (!blockId)
4495
+ continue;
4496
+ const scriptTestsDir = join(transformsDir2, `${scriptName}.tests`);
4497
+ if (!existsSync(scriptTestsDir))
4498
+ continue;
4499
+ // Populate scriptConfigNameToId for this script so a test case that
4500
+ // pins a config by name resolves to a live configId.
4501
+ try {
4502
+ const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, blockId);
4503
+ for (const config of scriptConfigs) {
4504
+ scriptConfigNameToId.set(`${scriptName}#${config.configName}`, config.configId);
4505
+ }
4506
+ }
4507
+ catch {
4508
+ // Non-fatal — unpinned (active-version) test cases still push.
4509
+ }
4510
+ skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "script", blockId, scriptName, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
4511
+ }
4512
+ }
4095
4513
  divider();
4096
4514
  // Issue #813: a gate failure surfaced by the validate-first pass (which
4097
4515
  // also runs in dry-run mode via the server's dry-run gate endpoints).
@@ -4180,13 +4598,9 @@ Directory Structure:
4180
4598
  // Don't mask the original error
4181
4599
  }
4182
4600
  }
4183
- error(err.message);
4184
- // Print structured server-side validation details (issue #684).
4185
- if (err instanceof ApiError && Array.isArray(err.details) && err.details.length > 0) {
4186
- for (const detail of err.details) {
4187
- console.error(` - ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
4188
- }
4189
- }
4601
+ // Print message + structured server-side validation details
4602
+ // (issue #684; now via the shared #1173 helper).
4603
+ printApiError(err);
4190
4604
  process.exit(1);
4191
4605
  }
4192
4606
  });
@@ -4309,6 +4723,30 @@ Directory Structure:
4309
4723
  localEmailTemplates.add(emailType);
4310
4724
  }
4311
4725
  }
4726
+ // Transforms (Rhai scripts) — issue #1196. Before this, `sync diff`
4727
+ // ignored scripts entirely, so a body that drifted between the local
4728
+ // `.rhai` and the server's active config silently read as Synced. We
4729
+ // list the server scripts (header only) and read the local file
4730
+ // contents so the comparison below can hash both sides.
4731
+ let scriptItemsDiff = [];
4732
+ try {
4733
+ const scriptsResult = await client.listScripts(resolvedAppId);
4734
+ scriptItemsDiff = scriptsResult.items || [];
4735
+ }
4736
+ catch {
4737
+ // Older server without the scripts route — treat as no scripts.
4738
+ }
4739
+ const remoteScripts = new Map(scriptItemsDiff
4740
+ .filter((s) => s?.name)
4741
+ .map((s) => [s.name, s]));
4742
+ const localScripts = new Map();
4743
+ const transformsDirPath = join(configDir, "transforms");
4744
+ if (existsSync(transformsDirPath)) {
4745
+ for (const file of readdirSync(transformsDirPath).filter((f) => f.endsWith(".rhai"))) {
4746
+ const name = basename(file, ".rhai");
4747
+ localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
4748
+ }
4749
+ }
4312
4750
  // Compare
4313
4751
  const differences = [];
4314
4752
  // Integrations
@@ -4464,6 +4902,55 @@ Directory Structure:
4464
4902
  differences.push({ type: "email-template", key, status: "remote only" });
4465
4903
  }
4466
4904
  }
4905
+ // Transforms (Rhai scripts) — issue #1196. Content-aware, mirroring
4906
+ // the workflow block: for scripts present on both sides we fetch the
4907
+ // server's active-config body (via `getScript`, the same call pull
4908
+ // uses) and compare it to the local `.rhai` contents. A difference is
4909
+ // reported as `modified`, framed as a preview of `sync pull`. A
4910
+ // per-script fetch failure degrades to key-only (Synced, not compared)
4911
+ // rather than aborting the whole diff.
4912
+ for (const [name, localBody] of localScripts) {
4913
+ const remote = remoteScripts.get(name);
4914
+ if (!remote) {
4915
+ differences.push({ type: "transform", key: name, status: "local only" });
4916
+ continue;
4917
+ }
4918
+ let status = "exists";
4919
+ let hint;
4920
+ try {
4921
+ if (!remote.activeConfigId) {
4922
+ // No active config server-side — nothing to compare against.
4923
+ status = "exists";
4924
+ hint = "content not compared (no active config)";
4925
+ }
4926
+ else {
4927
+ const full = await client.getScript(resolvedAppId, remote.scriptId);
4928
+ const configs = Array.isArray(full?.configs) ? full.configs : [];
4929
+ const activeConfigId = full?.activeConfigId ?? remote.activeConfigId;
4930
+ const activeConfig = configs.find((c) => c.configId === activeConfigId) ||
4931
+ configs.find((c) => c.status === "active");
4932
+ if (!activeConfig || typeof activeConfig.body !== "string") {
4933
+ status = "exists";
4934
+ hint = "content not compared (no active config body)";
4935
+ }
4936
+ else if (activeConfig.body !== localBody) {
4937
+ status = "modified";
4938
+ hint = "run `sync pull` to rewrite the local .rhai to match running state";
4939
+ }
4940
+ }
4941
+ }
4942
+ catch {
4943
+ // Per-script fetch failure must not abort the whole diff.
4944
+ status = "exists";
4945
+ hint = "content not compared (fetch failed)";
4946
+ }
4947
+ differences.push({ type: "transform", key: name, status, hint });
4948
+ }
4949
+ for (const name of remoteScripts.keys()) {
4950
+ if (!localScripts.has(name)) {
4951
+ differences.push({ type: "transform", key: name, status: "remote only" });
4952
+ }
4953
+ }
4467
4954
  // Compare test cases for synced prompts and workflows
4468
4955
  const testCaseDiffs = [];
4469
4956
  // Helper to compare test cases for a block
@@ -4699,6 +5186,63 @@ Directory Structure:
4699
5186
  success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
4700
5187
  }
4701
5188
  });
5189
+ // migrate-v2 — convert a legacy sync directory into the unified block
5190
+ // layout `blocks/<type>/<key>/block.toml` (issue #1183, Phase 3). Purely
5191
+ // local: it reads already-pulled legacy files and writes the v2 tree. The
5192
+ // legacy files are left in place (legacy stays the default until v2 is
5193
+ // proven), so re-running push/pull is unaffected.
5194
+ sync
5195
+ .command("migrate-v2")
5196
+ .description("Convert legacy per-type sync files (prompts/, integrations/, workflows/, transforms/) into the v2 blocks/<type>/<key>/block.toml layout")
5197
+ .argument("[app-id]", "App ID (uses current app if not specified)")
5198
+ .option("--app <app-id>", "App ID")
5199
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
5200
+ .option("--dry-run", "Show what would change without writing files")
5201
+ .option("--force", "Overwrite existing block.toml files instead of skipping them")
5202
+ .action(async (appId, options) => {
5203
+ // Resolve appId so we land in the per-env sync dir even when the user
5204
+ // didn't pass `--dir`. The app ID itself isn't used for any API calls —
5205
+ // migrate-v2 is a purely local rewrite (like migrate-toml).
5206
+ const resolvedAppId = resolveAppId(appId, options);
5207
+ const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
5208
+ if (isAutoResolvedSyncDir(options.dir)) {
5209
+ info(`Using per-environment sync directory: ${configDir}`);
5210
+ }
5211
+ if (!existsSync(configDir)) {
5212
+ error(`Config directory not found: ${configDir}`);
5213
+ process.exit(1);
5214
+ }
5215
+ const before = detectLayout(configDir);
5216
+ if (!before.legacy) {
5217
+ info("No legacy block files found (prompts/, integrations/, workflows/, transforms/); nothing to migrate.");
5218
+ return;
5219
+ }
5220
+ const result = migrateLegacyToV2(configDir, {
5221
+ dryRun: options.dryRun,
5222
+ force: options.force,
5223
+ });
5224
+ for (const b of result.blocks) {
5225
+ const suffix = b.testsCopied > 0 ? ` (+${b.testsCopied} test case(s))` : "";
5226
+ if (options.dryRun) {
5227
+ info(`Would write blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
5228
+ }
5229
+ else {
5230
+ info(`Wrote blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
5231
+ }
5232
+ }
5233
+ for (const s of result.skipped) {
5234
+ warn(`Skipped ${s.type}/${s.key}: ${s.reason}`);
5235
+ }
5236
+ divider();
5237
+ keyValue("Migrated", result.blocks.length);
5238
+ keyValue("Skipped", result.skipped.length);
5239
+ if (options.dryRun) {
5240
+ info("Dry-run only — no files were modified.");
5241
+ }
5242
+ else if (result.blocks.length > 0) {
5243
+ success(`Converted ${result.blocks.length} block(s) to the v2 layout. Legacy files were left in place.`);
5244
+ }
5245
+ });
4702
5246
  // Revert — restore a pre-pull snapshot (issue #578, Phase 1).
4703
5247
  sync
4704
5248
  .command("revert")