primitive-admin 1.1.0-alpha.38 → 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 (57) 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/group-type-configs.js +1 -9
  9. package/dist/src/commands/group-type-configs.js.map +1 -1
  10. package/dist/src/commands/guides.d.ts +75 -0
  11. package/dist/src/commands/guides.js +284 -47
  12. package/dist/src/commands/guides.js.map +1 -1
  13. package/dist/src/commands/rule-sets.d.ts +1 -0
  14. package/dist/src/commands/rule-sets.js +25 -3
  15. package/dist/src/commands/rule-sets.js.map +1 -1
  16. package/dist/src/commands/scripts.d.ts +2 -0
  17. package/dist/src/commands/scripts.js +667 -0
  18. package/dist/src/commands/scripts.js.map +1 -0
  19. package/dist/src/commands/sync.d.ts +29 -0
  20. package/dist/src/commands/sync.js +402 -97
  21. package/dist/src/commands/sync.js.map +1 -1
  22. package/dist/src/commands/webhooks.js +7 -2
  23. package/dist/src/commands/webhooks.js.map +1 -1
  24. package/dist/src/commands/workflows.js +219 -1
  25. package/dist/src/commands/workflows.js.map +1 -1
  26. package/dist/src/lib/api-client.d.ts +56 -15
  27. package/dist/src/lib/api-client.js +51 -0
  28. package/dist/src/lib/api-client.js.map +1 -1
  29. package/dist/src/lib/block-layout.d.ts +160 -0
  30. package/dist/src/lib/block-layout.js +451 -0
  31. package/dist/src/lib/block-layout.js.map +1 -0
  32. package/dist/src/lib/config.js +21 -0
  33. package/dist/src/lib/config.js.map +1 -1
  34. package/dist/src/lib/generated-allowlist.js +9 -0
  35. package/dist/src/lib/generated-allowlist.js.map +1 -1
  36. package/dist/src/lib/output.d.ts +25 -5
  37. package/dist/src/lib/output.js +32 -4
  38. package/dist/src/lib/output.js.map +1 -1
  39. package/dist/src/lib/query-operators.d.ts +43 -0
  40. package/dist/src/lib/query-operators.js +80 -0
  41. package/dist/src/lib/query-operators.js.map +1 -0
  42. package/dist/src/lib/toml-database-config.d.ts +20 -0
  43. package/dist/src/lib/toml-database-config.js +37 -2
  44. package/dist/src/lib/toml-database-config.js.map +1 -1
  45. package/dist/src/lib/toml-metadata-config.d.ts +108 -0
  46. package/dist/src/lib/toml-metadata-config.js +371 -0
  47. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  48. package/dist/src/lib/toml-params-validator.d.ts +34 -0
  49. package/dist/src/lib/toml-params-validator.js +118 -3
  50. package/dist/src/lib/toml-params-validator.js.map +1 -1
  51. package/dist/src/lib/workflow-apply.d.ts +66 -0
  52. package/dist/src/lib/workflow-apply.js +117 -0
  53. package/dist/src/lib/workflow-apply.js.map +1 -0
  54. package/dist/src/lib/workflow-toml-validator.js +36 -0
  55. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  56. package/dist/src/types/index.d.ts +7 -0
  57. package/package.json +1 -1
@@ -5,12 +5,15 @@ 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";
15
+ import { applyWorkflowBody } from "../lib/workflow-apply.js";
16
+ import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
14
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";
@@ -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) {
@@ -963,8 +1094,10 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
963
1094
  const configMapKey = `${blockKey}#${testPayload._configName}`;
964
1095
  const configMap = blockType === "prompt"
965
1096
  ? resolutionMaps.promptConfigNameToId
966
- : resolutionMaps.workflowConfigNameToId;
967
- const resolvedId = configMap.get(configMapKey);
1097
+ : blockType === "script"
1098
+ ? resolutionMaps.scriptConfigNameToId
1099
+ : resolutionMaps.workflowConfigNameToId;
1100
+ const resolvedId = configMap?.get(configMapKey);
968
1101
  if (resolvedId) {
969
1102
  testPayload.configId = resolvedId;
970
1103
  }
@@ -1166,6 +1299,7 @@ Directory Structure:
1166
1299
  rule-sets/*.toml # Access rule sets
1167
1300
  group-type-configs/*.toml # Group type configs
1168
1301
  collection-type-configs/*.toml # Collection type configs
1302
+ metadata-category-configs/*.toml # Resource-metadata category configs
1169
1303
  email-templates/*.toml # Email template overrides
1170
1304
  `);
1171
1305
  // Init
@@ -1196,6 +1330,7 @@ Directory Structure:
1196
1330
  ensureDir(join(configDir, "rule-sets"));
1197
1331
  ensureDir(join(configDir, "group-type-configs"));
1198
1332
  ensureDir(join(configDir, "collection-type-configs"));
1333
+ ensureDir(join(configDir, "metadata-category-configs"));
1199
1334
  ensureDir(join(configDir, "email-templates"));
1200
1335
  const state = {
1201
1336
  appId: resolvedAppId,
@@ -1267,11 +1402,12 @@ Directory Structure:
1267
1402
  return;
1268
1403
  }
1269
1404
  // Fetch database config resources
1270
- const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult,] = await Promise.all([
1405
+ const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult, metadataCategoryConfigsResult,] = await Promise.all([
1271
1406
  client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
1272
1407
  client.listRuleSets(resolvedAppId).catch(() => []),
1273
1408
  client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
1274
1409
  client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
1410
+ client.listMetadataCategoryConfigs(resolvedAppId).catch(() => []),
1275
1411
  ]);
1276
1412
  // Fetch operations + subscriptions for each database type. Issue #803:
1277
1413
  // subscriptions are pulled symmetrically with operations so a
@@ -1330,6 +1466,7 @@ Directory Structure:
1330
1466
  ensureDir(join(configDir, "rule-sets"));
1331
1467
  ensureDir(join(configDir, "group-type-configs"));
1332
1468
  ensureDir(join(configDir, "collection-type-configs"));
1469
+ ensureDir(join(configDir, "metadata-category-configs"));
1333
1470
  ensureDir(join(configDir, "email-templates"));
1334
1471
  // Write app settings
1335
1472
  if (settings) {
@@ -1468,6 +1605,23 @@ Directory Structure:
1468
1605
  }
1469
1606
  }
1470
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
+ }
1471
1625
  const testCaseLookupMaps = { configIdToName, promptIdToKey };
1472
1626
  // Write email templates (use detailed emailTemplates, not summaries)
1473
1627
  const emailTemplateEntities = {};
@@ -1510,6 +1664,15 @@ Directory Structure:
1510
1664
  }
1511
1665
  totalTestCases += count;
1512
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
+ }
1513
1676
  // Build ruleSetId → name map for database types and group type configs
1514
1677
  const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
1515
1678
  const ruleSetIdToName = new Map();
@@ -1605,6 +1768,23 @@ Directory Structure:
1605
1768
  };
1606
1769
  info(` Wrote collection-type-configs/${filename}`);
1607
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
+ }
1608
1788
  // Save sync state
1609
1789
  const state = {
1610
1790
  appId: resolvedAppId,
@@ -1627,6 +1807,9 @@ Directory Structure:
1627
1807
  collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
1628
1808
  ? collectionTypeConfigEntities
1629
1809
  : undefined,
1810
+ metadataCategoryConfigs: Object.keys(metadataCategoryConfigEntities).length > 0
1811
+ ? metadataCategoryConfigEntities
1812
+ : undefined,
1630
1813
  },
1631
1814
  };
1632
1815
  saveSyncState(configDir, state);
@@ -1645,6 +1828,7 @@ Directory Structure:
1645
1828
  keyValue("Rule Sets", ruleSets.length);
1646
1829
  keyValue("Group Type Configs", groupTypeConfigs.length);
1647
1830
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
1831
+ keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
1648
1832
  }
1649
1833
  catch (err) {
1650
1834
  error(err.message);
@@ -1920,11 +2104,10 @@ Directory Structure:
1920
2104
  }
1921
2105
  }
1922
2106
  if (tomlData.auth) {
1923
- settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
1924
- settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
1925
- settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
1926
- if (tomlData.auth.passkeys) {
1927
- 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}`);
1928
2111
  }
1929
2112
  }
1930
2113
  if (tomlData.cors) {
@@ -2142,6 +2325,14 @@ Directory Structure:
2142
2325
  deduplicationWindowMs: webhook.deduplicationWindowMs,
2143
2326
  secretGracePeriodMs: webhook.secretGracePeriodMs,
2144
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
+ }
2145
2336
  // Handle JSON fields
2146
2337
  if (tomlData.allowedIps?.cidrs) {
2147
2338
  payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
@@ -2774,6 +2965,19 @@ Directory Structure:
2774
2965
  const workflow = tomlData.workflow || {};
2775
2966
  const key = workflow.key || basename(file, ".toml");
2776
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;
2777
2981
  const existingId = syncState?.entities?.workflows?.[key]?.id;
2778
2982
  const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
2779
2983
  // Skip if file hasn't changed since last sync. Use the expanded
@@ -2805,80 +3009,17 @@ Directory Structure:
2805
3009
  // out-of-manifest workflow).
2806
3010
  const applyWorkflowUpdate = async (workflowId, opts) => {
2807
3011
  const { expectedModifiedAt, activeConfigId } = opts;
2808
- // Update workflow metadata and schemas.
2809
- //
2810
- // Sync-callable ordering (#807, codex review): the metadata
2811
- // PATCH must NOT carry `syncCallable: true` here. The server
2812
- // re-validates `syncCallable: true` against the workflow's
2813
- // CURRENTLY-active server steps (`loadCurrentActiveSteps`),
2814
- // not the steps being pushed in the same sync. So when a
2815
- // single TOML edit both enables `syncCallable` and replaces
2816
- // sync-incompatible active steps with compatible ones, a
2817
- // combined metadata-first PATCH would be rejected against the
2818
- // stale steps. Defer `syncCallable` to a second PATCH issued
2819
- // AFTER the config/step update lands, so it validates against
2820
- // the new (compatible) active steps. `requiresClientApply`
2821
- // has no step-dependent validation, so it stays in the first
2822
- // PATCH unchanged.
2823
- const updated = await client.updateWorkflow(resolvedAppId, workflowId, {
2824
- name: workflow.name,
2825
- description: workflow.description,
2826
- status: workflow.status,
2827
- accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
2828
- // #1081 — workflow principal mode. Absent key → undefined
2829
- // (server leaves it untouched); explicit value/empty is
2830
- // forwarded so it round-trips.
2831
- runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
2832
- perUserMaxRunning: workflow.perUserMaxRunning,
2833
- perUserMaxQueued: workflow.perUserMaxQueued,
2834
- dequeueOrder: workflow.dequeueOrder,
2835
- // Sync-callable flags (#807): pass through as-is. An absent
2836
- // TOML key is `undefined` (dropped by JSON.stringify, so the
2837
- // server's hasOwnProperty guard leaves the value untouched);
2838
- // an explicit `false` is preserved as a meaningful value.
2839
- requiresClientApply: workflow.requiresClientApply,
2840
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2841
- outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
2842
- }, expectedModifiedAt);
2843
- // Track the latest workflow `modifiedAt` for the sync-state
2844
- // write below. The first PATCH bumps it; the `syncCallable`
2845
- // PATCH (if any) bumps it again. The config/step update does
2846
- // NOT touch the workflow definition's `modifiedAt`.
2847
- let latestModifiedAt = updated?.workflow?.modifiedAt;
2848
- // Update active configuration steps (or draft for legacy).
2849
- // Issue #687: name the slot we touched so the dev-loop
2850
- // user can confirm before previewing.
2851
- let updateSlotLabel = "active config";
2852
- if (activeConfigId) {
2853
- await client.updateWorkflowConfig(resolvedAppId, workflowId, activeConfigId, {
2854
- steps,
2855
- });
2856
- }
2857
- else {
2858
- // Fallback to draft update for legacy workflows
2859
- await client.updateWorkflowDraft(resolvedAppId, workflowId, {
2860
- steps,
2861
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2862
- });
2863
- updateSlotLabel = "draft (legacy)";
2864
- }
2865
- // Second PATCH: apply `syncCallable` now that the new steps
2866
- // are active (#807). Only sent when the TOML actually carries
2867
- // the key — an absent key stays `undefined` and we skip the
2868
- // call entirely, preserving the no-clobber discipline. Chain
2869
- // `expectedModifiedAt` off the first PATCH's returned value so
2870
- // optimistic concurrency stays intact (the step update above
2871
- // doesn't change the workflow definition's `modifiedAt`).
2872
- if (workflow.syncCallable !== undefined) {
2873
- const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, workflowId, { syncCallable: workflow.syncCallable },
2874
- // Mirror the first PATCH's concurrency posture: `--force`
2875
- // skips the check (undefined), otherwise reuse the fresh
2876
- // `modifiedAt` from the first PATCH.
2877
- options.force ? undefined : latestModifiedAt);
2878
- if (syncCallableUpdated?.workflow?.modifiedAt) {
2879
- latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
2880
- }
2881
- }
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
+ });
2882
3023
  info(` Updated workflow: ${key} (${updateSlotLabel})`);
2883
3024
  // Update sync state with new modifiedAt. Store the *expanded*
2884
3025
  // content hash so future fragment-only edits are detected. The
@@ -2888,9 +3029,8 @@ Directory Structure:
2888
3029
  syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
2889
3030
  syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
2890
3031
  }
2891
- // Fetch full workflow to get config name→ID mappings
2892
- // (updateWorkflow response doesn't include configs)
2893
- const fullWorkflow = await client.getWorkflow(resolvedAppId, workflowId);
3032
+ // `applyWorkflowBody` already fetched the full workflow; reuse it
3033
+ // for the config name→ID mappings (the PATCH response omits configs).
2894
3034
  if (fullWorkflow?.configs) {
2895
3035
  for (const config of fullWorkflow.configs) {
2896
3036
  workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
@@ -2949,6 +3089,9 @@ Directory Structure:
2949
3089
  // syncCallable=false); an explicit value is honored.
2950
3090
  requiresClientApply: workflow.requiresClientApply,
2951
3091
  syncCallable: workflow.syncCallable,
3092
+ // #1304 (P-C): declared-access manifest (absent → undefined,
3093
+ // server default null).
3094
+ metadataManifest: workflow.metadataManifest ?? undefined,
2952
3095
  });
2953
3096
  info(` Created workflow: ${key}`);
2954
3097
  // Add new entity to sync state (including activeConfigId)
@@ -3214,6 +3357,14 @@ Directory Structure:
3214
3357
  if ("timestamps" in typeConfig) {
3215
3358
  u.timestamps = typeConfig.timestamps || null;
3216
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
+ }
3217
3368
  const localHasSchema = typeof typeConfig.schema === "string" &&
3218
3369
  typeConfig.schema.trim().length > 0;
3219
3370
  if (localHasSchema) {
@@ -3583,7 +3734,8 @@ Directory Structure:
3583
3734
  "defaultAccess" in typeConfig ||
3584
3735
  "autoPopulatedFields" in typeConfig ||
3585
3736
  "timestamps" in typeConfig ||
3586
- "schema" in typeConfig;
3737
+ "schema" in typeConfig ||
3738
+ "metadataManifest" in typeConfig;
3587
3739
  if (wouldUpdate) {
3588
3740
  changes.push({ type: "database-type", action: "update", key: dbType });
3589
3741
  }
@@ -3616,6 +3768,9 @@ Directory Structure:
3616
3768
  // validating against the intended shape.
3617
3769
  if (typeConfig.schema)
3618
3770
  createData.schema = typeConfig.schema;
3771
+ // Issue #1304 (P-C): declared-access manifest.
3772
+ if (typeConfig.metadataManifest)
3773
+ createData.metadataManifest = typeConfig.metadataManifest;
3619
3774
  try {
3620
3775
  const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
3621
3776
  info(` Created database type: ${dbType}`);
@@ -4149,6 +4304,7 @@ Directory Structure:
4149
4304
  try {
4150
4305
  const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
4151
4306
  ruleSetId: configData.ruleSetId ?? null,
4307
+ metadataManifest: configData.metadataManifest ?? null,
4152
4308
  }, expectedModifiedAt);
4153
4309
  info(` Updated collection type config: ${collectionType}`);
4154
4310
  if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
@@ -4179,6 +4335,9 @@ Directory Structure:
4179
4335
  const created = await client.createCollectionTypeConfig(resolvedAppId, {
4180
4336
  collectionType,
4181
4337
  ruleSetId: configData.ruleSetId || undefined,
4338
+ ...(configData.metadataManifest
4339
+ ? { metadataManifest: configData.metadataManifest }
4340
+ : {}),
4182
4341
  });
4183
4342
  info(` Created collection type config: ${collectionType}`);
4184
4343
  if (syncState) {
@@ -4198,6 +4357,66 @@ Directory Structure:
4198
4357
  }
4199
4358
  }
4200
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
+ }
4201
4420
  // Process email templates
4202
4421
  const emailTemplatesDir = join(configDir, "email-templates");
4203
4422
  if (existsSync(emailTemplatesDir)) {
@@ -4234,11 +4453,13 @@ Directory Structure:
4234
4453
  }
4235
4454
  }
4236
4455
  }
4237
- // Push test cases for prompts and workflows
4456
+ // Push test cases for prompts, workflows, and scripts
4457
+ const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
4238
4458
  const pushMaps = {
4239
4459
  promptKeyToId,
4240
4460
  promptConfigNameToId,
4241
4461
  workflowConfigNameToId,
4462
+ scriptConfigNameToId,
4242
4463
  };
4243
4464
  const promptsDir2 = join(configDir, "prompts");
4244
4465
  if (existsSync(promptsDir2)) {
@@ -4262,6 +4483,33 @@ Directory Structure:
4262
4483
  }
4263
4484
  }
4264
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
+ }
4265
4513
  divider();
4266
4514
  // Issue #813: a gate failure surfaced by the validate-first pass (which
4267
4515
  // also runs in dry-run mode via the server's dry-run gate endpoints).
@@ -4938,6 +5186,63 @@ Directory Structure:
4938
5186
  success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
4939
5187
  }
4940
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
+ });
4941
5246
  // Revert — restore a pre-pull snapshot (issue #578, Phase 1).
4942
5247
  sync
4943
5248
  .command("revert")