primitive-admin 1.1.0-alpha.74 → 1.1.0-alpha.76

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 (128) hide show
  1. package/dist/bin/primitive.js +110 -21
  2. package/dist/bin/primitive.js.map +1 -1
  3. package/dist/src/commands/collection-type-configs.js +1 -1
  4. package/dist/src/commands/collection-type-configs.js.map +1 -1
  5. package/dist/src/commands/config.js +8 -4
  6. package/dist/src/commands/config.js.map +1 -1
  7. package/dist/src/commands/database-type-configs.js +1 -1
  8. package/dist/src/commands/database-type-configs.js.map +1 -1
  9. package/dist/src/commands/databases.js +25 -31
  10. package/dist/src/commands/databases.js.map +1 -1
  11. package/dist/src/commands/documents.js +9 -24
  12. package/dist/src/commands/documents.js.map +1 -1
  13. package/dist/src/commands/env.d.ts +1 -1
  14. package/dist/src/commands/env.js +14 -11
  15. package/dist/src/commands/env.js.map +1 -1
  16. package/dist/src/commands/functions.js +173 -4
  17. package/dist/src/commands/functions.js.map +1 -1
  18. package/dist/src/commands/groups.js +1 -1
  19. package/dist/src/commands/groups.js.map +1 -1
  20. package/dist/src/commands/init.d.ts +12 -15
  21. package/dist/src/commands/init.js +36 -77
  22. package/dist/src/commands/init.js.map +1 -1
  23. package/dist/src/commands/prompts.js +54 -39
  24. package/dist/src/commands/prompts.js.map +1 -1
  25. package/dist/src/commands/rule-sets.js +1 -1
  26. package/dist/src/commands/rule-sets.js.map +1 -1
  27. package/dist/src/commands/scripts.js +17 -4
  28. package/dist/src/commands/scripts.js.map +1 -1
  29. package/dist/src/commands/sync-app-settings.js +2 -4
  30. package/dist/src/commands/sync-app-settings.js.map +1 -1
  31. package/dist/src/commands/sync.d.ts +41 -2
  32. package/dist/src/commands/sync.js +635 -364
  33. package/dist/src/commands/sync.js.map +1 -1
  34. package/dist/src/commands/workflows.js +62 -31
  35. package/dist/src/commands/workflows.js.map +1 -1
  36. package/dist/src/lib/api-client.d.ts +23 -2
  37. package/dist/src/lib/api-client.js +24 -5
  38. package/dist/src/lib/api-client.js.map +1 -1
  39. package/dist/src/lib/app-match-guard.d.ts +44 -0
  40. package/dist/src/lib/app-match-guard.js +72 -0
  41. package/dist/src/lib/app-match-guard.js.map +1 -0
  42. package/dist/src/lib/block-selector.d.ts +58 -0
  43. package/dist/src/lib/block-selector.js +92 -0
  44. package/dist/src/lib/block-selector.js.map +1 -0
  45. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +11 -8
  46. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +108 -54
  47. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  48. package/dist/src/lib/config-object-descriptor.js +1 -3
  49. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  50. package/dist/src/lib/config.d.ts +2 -2
  51. package/dist/src/lib/config.js +2 -2
  52. package/dist/src/lib/credentials-store.d.ts +3 -3
  53. package/dist/src/lib/credentials-store.js +3 -3
  54. package/dist/src/lib/credentials-store.js.map +1 -1
  55. package/dist/src/lib/document-export-permissions.d.ts +30 -0
  56. package/dist/src/lib/document-export-permissions.js +54 -0
  57. package/dist/src/lib/document-export-permissions.js.map +1 -0
  58. package/dist/src/lib/env-resolver-core.d.ts +41 -9
  59. package/dist/src/lib/env-resolver-core.js +84 -13
  60. package/dist/src/lib/env-resolver-core.js.map +1 -1
  61. package/dist/src/lib/env-resolver.d.ts +3 -3
  62. package/dist/src/lib/env-resolver.js +2 -2
  63. package/dist/src/lib/function-bundle.d.ts +11 -1
  64. package/dist/src/lib/function-bundle.js +13 -1
  65. package/dist/src/lib/function-bundle.js.map +1 -1
  66. package/dist/src/lib/function-collect.d.ts +123 -0
  67. package/dist/src/lib/function-collect.js +463 -0
  68. package/dist/src/lib/function-collect.js.map +1 -0
  69. package/dist/src/lib/function-db-types.d.ts +149 -0
  70. package/dist/src/lib/function-db-types.js +587 -0
  71. package/dist/src/lib/function-db-types.js.map +1 -0
  72. package/dist/src/lib/function-grants-preflight.d.ts +64 -0
  73. package/dist/src/lib/function-grants-preflight.js +100 -0
  74. package/dist/src/lib/function-grants-preflight.js.map +1 -0
  75. package/dist/src/lib/function-sync.d.ts +23 -0
  76. package/dist/src/lib/function-sync.js +65 -1
  77. package/dist/src/lib/function-sync.js.map +1 -1
  78. package/dist/src/lib/function-triggers.d.ts +6 -0
  79. package/dist/src/lib/function-triggers.js +63 -4
  80. package/dist/src/lib/function-triggers.js.map +1 -1
  81. package/dist/src/lib/generated-config-surfaces.d.ts +340 -0
  82. package/dist/src/lib/generated-config-surfaces.js +669 -17
  83. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  84. package/dist/src/lib/generated-sdk-types.d.ts +12 -0
  85. package/dist/src/lib/generated-sdk-types.js +13 -0
  86. package/dist/src/lib/generated-sdk-types.js.map +1 -0
  87. package/dist/src/lib/init-adopt.js +2 -2
  88. package/dist/src/lib/init-adopt.js.map +1 -1
  89. package/dist/src/lib/init-assets.js +3 -3
  90. package/dist/src/lib/init-config.d.ts +1 -1
  91. package/dist/src/lib/init-ios-links.js +1 -1
  92. package/dist/src/lib/init-ios-links.js.map +1 -1
  93. package/dist/src/lib/init-plan.d.ts +1 -1
  94. package/dist/src/lib/init-plan.js +1 -1
  95. package/dist/src/lib/init-xcode.d.ts +2 -2
  96. package/dist/src/lib/init-xcode.js +6 -5
  97. package/dist/src/lib/init-xcode.js.map +1 -1
  98. package/dist/src/lib/local-state.d.ts +1 -1
  99. package/dist/src/lib/local-state.js +1 -1
  100. package/dist/src/lib/local-test-cases.d.ts +1 -1
  101. package/dist/src/lib/local-test-cases.js +2 -1
  102. package/dist/src/lib/local-test-cases.js.map +1 -1
  103. package/dist/src/lib/migration-nag.d.ts +2 -2
  104. package/dist/src/lib/migration-nag.js +4 -3
  105. package/dist/src/lib/migration-nag.js.map +1 -1
  106. package/dist/src/lib/project-config.d.ts +23 -20
  107. package/dist/src/lib/project-config.js +27 -45
  108. package/dist/src/lib/project-config.js.map +1 -1
  109. package/dist/src/lib/snapshots.d.ts +2 -2
  110. package/dist/src/lib/snapshots.js +2 -2
  111. package/dist/src/lib/swift-codegen/generator.d.ts +1 -1
  112. package/dist/src/lib/swift-codegen/generator.js +1 -1
  113. package/dist/src/lib/sync-dir-selector.d.ts +1 -1
  114. package/dist/src/lib/sync-dir-selector.js +1 -1
  115. package/dist/src/lib/sync-paths.d.ts +52 -16
  116. package/dist/src/lib/sync-paths.js +68 -22
  117. package/dist/src/lib/sync-paths.js.map +1 -1
  118. package/dist/src/lib/sync-resource-types.d.ts +2 -2
  119. package/dist/src/lib/sync-resource-types.js +2 -2
  120. package/dist/src/lib/template.d.ts +2 -2
  121. package/dist/src/lib/template.js +2 -2
  122. package/dist/src/lib/test-case-keys.d.ts +1 -1
  123. package/dist/src/lib/test-case-keys.js +1 -1
  124. package/dist/src/lib/toml-database-config.js +19 -2
  125. package/dist/src/lib/toml-database-config.js.map +1 -1
  126. package/dist/src/lib/workflow-codegen/generator.d.ts +1 -1
  127. package/dist/src/lib/workflow-codegen/generator.js +1 -1
  128. package/package.json +3 -3
@@ -791,6 +791,35 @@ export const RETIRED_CONFIG_KEYS = {
791
791
  "`primitive functions enable|disable` to change availability, or " +
792
792
  "`primitive functions archive` to retire the function (#2907).",
793
793
  },
794
+ // #3279 — function code acts as the system. `runAs` chose between two
795
+ // authority models (forward the caller's, or the app's); there is one
796
+ // model now, so the key is not merely unnecessary: a file that still
797
+ // carries it is asking for a mode that no longer exists, and accepting
798
+ // and ignoring it would leave the author believing `"caller"` limited
799
+ // what the code could reach. A stored value on a pre-change row decides
800
+ // nothing anywhere.
801
+ runAs: {
802
+ toml: "`runAs` is no longer authored in TOML (#3279): function code acts as " +
803
+ "the system whoever invoked it, so there is no mode to choose. The " +
804
+ "`access` gate is the authorization, and `ctx.user` is the caller " +
805
+ "(null for a trigger fire). Delete the line and push again.",
806
+ request: "`runAs` is retired (#3279): function code acts as the system " +
807
+ "whoever invoked it, so there is no mode to choose. The `access` gate " +
808
+ "is the authorization; omit the field.",
809
+ },
810
+ // #3279 — the flag declared that a database READ grant read every row of
811
+ // a model for every caller. There is no database read grant: the
812
+ // per-model grammar is retired with the mode, and a function's reads are
813
+ // the app's own.
814
+ unscopedReads: {
815
+ toml: "`unscopedReads` is no longer authored in TOML (#3279): function code " +
816
+ "acts as the system, so there is no database read grant for the flag " +
817
+ "to widen — every records call inside a function carries the app's " +
818
+ "own authority. Delete the line and push again.",
819
+ request: "`unscopedReads` is retired (#3279): function code acts as the " +
820
+ "system, so there is no database read grant for the flag to widen. " +
821
+ "Omit the field.",
822
+ },
794
823
  },
795
824
  // #2803 — availability is one server-owned `status`. As a TOML key it made
796
825
  // `config push` a second writer: pushing a file pulled before an operator ran
@@ -811,6 +840,607 @@ export const RETIRED_CONFIG_KEYS = {
811
840
  export function retiredConfigKey(prefix, key) {
812
841
  return RETIRED_CONFIG_KEYS[prefix]?.[key] ?? null;
813
842
  }
843
+ // ── src/config-surface/function-grants.ts ────────────────────────────────
844
+ /**
845
+ * The capability grammar for server functions — #3182 phase 1, rewritten by
846
+ * #3279 (project `server-functions` phase 3).
847
+ *
848
+ * A function declares in its own TOML what it may CONFIGURE:
849
+ *
850
+ * capabilities = ["integration:stripe", "secret:STRIPE_KEY", "databases:delete"]
851
+ *
852
+ * ── Why the grammar is this small ────────────────────────────────────────
853
+ *
854
+ * The intent's decision (2026-09-09, "Authorization inside a function?"):
855
+ * function code acts as the system. The invocation gate is the authorization,
856
+ * and inside a function every platform call carries the app's own authority in
857
+ * every family. A capability is therefore never a statement about DATA — a
858
+ * model, a prompt, a channel, a member — because the function may reach all of
859
+ * it. It is declared only where it configures something:
860
+ *
861
+ * `integration:<key>` the egress allowlist — which upstream hosts the
862
+ * function's outbound calls may reach;
863
+ * `secret:<NAME>` credential least privilege — which secret VALUES may
864
+ * cross into the sandbox at all;
865
+ * the high-blast list {@link HIGH_BLAST_CAPABILITIES} — the operations
866
+ * whose blast radius the intent keeps opt-in.
867
+ *
868
+ * Every other string a function used to declare is RETIRED, and the grammar
869
+ * says so by name: an author who still writes `database:orders/Order:read`
870
+ * is told the model changed and what stays, not "unknown family", which would
871
+ * send them to check their spelling.
872
+ *
873
+ * ── Why the components have a charset ────────────────────────────────────
874
+ *
875
+ * A keyed grant's key must match `[A-Za-z0-9_-]+`, so neither `:` nor `/` can
876
+ * enter a component and the string is INJECTIVE — one string, one object. An
877
+ * integration or secret whose key carries a delimiter is simply unreachable
878
+ * from functions, with an error that says why (D3182-001's argument, kept).
879
+ *
880
+ * ── Why this module is pure ──────────────────────────────────────────────
881
+ *
882
+ * Capabilities are validated twice — by `config push`'s preflight, so an
883
+ * author sees the error against their own file, and by the server, which is
884
+ * authoritative because the raw admin API exists. Two enforcement points must
885
+ * not be two grammars, so the grammar lives here, in the dependency-free
886
+ * `src/config-surface/` tree the CLI vendors at build time
887
+ * (`cli/scripts/gen-config-surfaces.mjs`). The server imports this module; the
888
+ * CLI imports the generated copy; the drift guard fails if they differ.
889
+ */
890
+ /** Every component of a grant. Injectivity depends on this. */
891
+ export const GRANT_COMPONENT_PATTERN = /^[A-Za-z0-9_-]+$/;
892
+ /** `ServerFunctionConfig.capabilities` is a StringSet with these bounds. */
893
+ export const MAX_CAPABILITY_ENTRIES = 100;
894
+ export const MAX_CAPABILITY_ENTRY_LENGTH = 200;
895
+ /**
896
+ * The two keyed families the intent keeps: one component under
897
+ * {@link GRANT_COMPONENT_PATTERN}, naming a single object. Every key format the
898
+ * platform issues fits: an integration key is `^[a-z0-9][a-z0-9-_]{2,}$` and a
899
+ * secret name is `^[A-Z][A-Z0-9_]{0,63}$`.
900
+ */
901
+ export const KEYED_GRANT_FAMILIES = ["integration", "secret"];
902
+ /**
903
+ * The high-blast-radius opt-ins — #3279, criterion 5 (CR3279-001, D3279-003).
904
+ *
905
+ * Since function code acts as the system, admission to a family is no longer
906
+ * an authority statement: everything the gateway lets through runs with the
907
+ * app's own authority. Most operations are fine that way — that is the whole
908
+ * decision. A short list is not, and the intent names its categories: delete a
909
+ * database, app or user; role changes; secret changes; resource provisioning.
910
+ * Those stay opt-in, so a function that can do them says so in a reviewable
911
+ * line of its TOML.
912
+ *
913
+ * The strings are EXACT and 1:1 with the operation id, so there is nothing to
914
+ * look up: `users.setRole` needs `users:setRole`. They parse with the verb as
915
+ * their KEY, because two exact capabilities in one family must not cover each
916
+ * other — `databases:create` is not permission to delete a database.
917
+ *
918
+ * The database ROLE mutations are here because they hand out persistent
919
+ * authority: a group grant assigns the manager role (D3279-003). App deletion
920
+ * and secret writes have no app-API route today; they are recorded, not gated,
921
+ * and the profile generator refuses to admit a future such route without a row
922
+ * here (`HIGH_BLAST_WATCH` in `scripts/lib/function-profile.mjs`).
923
+ */
924
+ export const HIGH_BLAST_CAPABILITIES = [
925
+ "databases:create",
926
+ "databases:delete",
927
+ "databases:transferOwnership",
928
+ "databases:addManager",
929
+ "databases:revokePermission",
930
+ "databases:grantGroupPermission",
931
+ "databases:revokeGroupPermission",
932
+ "users:remove",
933
+ "users:setRole",
934
+ "blobBuckets:createBucket",
935
+ "blobBuckets:deleteBucket",
936
+ ];
937
+ /** The exact strings, which since #3279 are exactly the high-blast list. */
938
+ export const EXACT_GRANT_STRINGS = HIGH_BLAST_CAPABILITIES;
939
+ /**
940
+ * What stays, in one sentence, for every retirement to point at. An author
941
+ * who reads a refusal should not need the guide to know what to write next.
942
+ */
943
+ const KEPT_KINDS = "Capabilities are declared only for `integration:<key>` (the egress " +
944
+ "allowlist), `secret:<NAME>` (which secret values may enter the sandbox) " +
945
+ "and the high-blast-radius opt-ins (" +
946
+ HIGH_BLAST_CAPABILITIES.map((entry) => `\`${entry}\``).join(", ") +
947
+ ").";
948
+ /**
949
+ * The retired FAMILIES (#3279), each with the reason it is gone.
950
+ *
951
+ * The value is what the family used to authorize, phrased as what a function
952
+ * now reaches without it. A refusal names the authored string, says it is
953
+ * retired, gives this reason, tells the author to delete the entry, and lists
954
+ * what stays.
955
+ */
956
+ export const RETIRED_GRANT_FAMILIES = {
957
+ database: "a database model needs no grant — every records call inside a function " +
958
+ "carries the app's own authority, and the models a function touches are " +
959
+ "collected at push as a manifest, not an authorization",
960
+ prompt: "a prompt needs no grant — `ctx.prompts.run` reaches every prompt of the " +
961
+ "app on the app's own authority",
962
+ var: "a config var needs no grant — `ctx.configVar` reads every var of the " +
963
+ "app on the app's own authority",
964
+ channel: "a channel needs no grant — `ctx.channels.authorize` and " +
965
+ "`ctx.channels.publish` reach every channel on the app's own authority",
966
+ };
967
+ /** The retired EXACT strings (#3279), on the same terms. */
968
+ export const RETIRED_GRANT_STRINGS = {
969
+ "users:send": "a send needs no grant — `ctx.users.send` reaches every member on the " +
970
+ "app's own authority",
971
+ "connections:send": "a send needs no grant — `ctx.connections.send` reaches every connection " +
972
+ "on the app's own authority",
973
+ "email:send": "sending mail needs no grant — `ctx.api.email.send` runs on the app's " +
974
+ "own authority, under the app's own hourly email budget",
975
+ "analytics:writeForUser": "an analytics write needs no grant — `ctx.api.analytics.writeForUser` " +
976
+ "runs on the app's own authority",
977
+ };
978
+ /**
979
+ * The retirement sentence for one authored string, or null when the string is
980
+ * not a retired one.
981
+ *
982
+ * Exported so the enforcement-time reader (`loadConfigGrants`) can tell a
983
+ * retired string on a pre-change row — tolerated, logged — from a string that
984
+ * was never a grant at all, which still authorizes nothing.
985
+ */
986
+ export function retiredGrantRefusal(raw) {
987
+ const colon = raw.indexOf(":");
988
+ const family = colon === -1 ? raw : raw.slice(0, colon);
989
+ const reason = RETIRED_GRANT_STRINGS[raw] ??
990
+ (family in RETIRED_GRANT_FAMILIES ? RETIRED_GRANT_FAMILIES[family] : null);
991
+ if (!reason)
992
+ return null;
993
+ return (`capability '${raw}' is retired (#3279): function code acts as the ` +
994
+ `system, so ${reason}. Delete the entry. ${KEPT_KINDS}`);
995
+ }
996
+ /** The keyed families' charset rule, as one sentence an author can act on. */
997
+ const KEY_CHARSET_GUIDANCE = "the key must match [A-Za-z0-9_-]+ so the grant string names exactly one " +
998
+ "object; a key carrying ':' or '/' is unreachable from functions and needs " +
999
+ "renaming to be granted";
1000
+ /**
1001
+ * What each keyed family's component is CALLED, so a refusal names the thing
1002
+ * the author got wrong rather than "the key".
1003
+ */
1004
+ const KEYED_FAMILY_NOUNS = {
1005
+ integration: "integration key",
1006
+ secret: "secret name",
1007
+ };
1008
+ /** The placeholder each family's grant shape uses, for the same reason. */
1009
+ const KEYED_FAMILY_PLACEHOLDERS = {
1010
+ integration: "key",
1011
+ secret: "NAME",
1012
+ };
1013
+ /** Every accepted shape, in one sentence, for the unknown-family error. */
1014
+ const ACCEPTED_FAMILIES = "`integration:<integrationKey>`, `secret:<NAME>`, and the high-blast " +
1015
+ "opt-ins " +
1016
+ HIGH_BLAST_CAPABILITIES.map((entry) => `\`${entry}\``).join(", ");
1017
+ /**
1018
+ * A channel name's ceiling, and the reason it has one.
1019
+ *
1020
+ * The name rides in a `ConnectionMapping` row's document-id slot as
1021
+ * `ch:<appId>:<channel>` and in every grant token's claims, so an unbounded
1022
+ * name would be an unbounded key and an unbounded credential. 200 characters
1023
+ * is {@link MAX_CAPABILITY_ENTRY_LENGTH}, kept for continuity with the rows
1024
+ * #3184 already wrote.
1025
+ */
1026
+ export const MAX_CHANNEL_NAME_LENGTH = 200;
1027
+ /**
1028
+ * A channel NAME, and its namespace.
1029
+ *
1030
+ * The grammar is the grant component charset applied per segment: one or more
1031
+ * `[A-Za-z0-9_-]+` segments joined by `:`. The `channel:<namespace>` GRANT
1032
+ * that used to cover a name is retired (#3279); the name grammar stays because
1033
+ * the authorize and publish routes answer 400 about a name outside it before
1034
+ * anything else, and the connection worker keys membership by it.
1035
+ *
1036
+ * Refusals name the segment that failed, because "channel name is invalid" on
1037
+ * a name like `orders:a::b` tells an author nothing they cannot already see.
1038
+ */
1039
+ export function parseChannelName(value) {
1040
+ if (typeof value !== "string" || value === "") {
1041
+ return {
1042
+ error: "a channel name must be a non-empty string of `[A-Za-z0-9_-]+` " +
1043
+ "segments joined by ':' (for example `orders` or `orders:42`)",
1044
+ };
1045
+ }
1046
+ if (value.length > MAX_CHANNEL_NAME_LENGTH) {
1047
+ return {
1048
+ error: `channel name '${value.slice(0, 40)}…' is ${value.length} characters, ` +
1049
+ `over the ${MAX_CHANNEL_NAME_LENGTH}-character limit for one channel.`,
1050
+ };
1051
+ }
1052
+ const segments = value.split(":");
1053
+ for (const segment of segments) {
1054
+ if (segment === "") {
1055
+ return {
1056
+ error: `channel name '${value}' has an empty segment: a channel name is ` +
1057
+ "one or more `[A-Za-z0-9_-]+` segments joined by ':', with no " +
1058
+ "leading, trailing, or doubled ':'.",
1059
+ };
1060
+ }
1061
+ if (!GRANT_COMPONENT_PATTERN.test(segment)) {
1062
+ return {
1063
+ error: `channel name '${value}' has the segment '${segment}', which is ` +
1064
+ "outside the channel charset: every segment must match " +
1065
+ "[A-Za-z0-9_-]+.",
1066
+ };
1067
+ }
1068
+ }
1069
+ return { namespace: segments[0] };
1070
+ }
1071
+ /**
1072
+ * One capability entry as a grant, or the reason it is not one.
1073
+ *
1074
+ * The single entry point the enforcement path and both preflights use: a
1075
+ * caller holds one authored string and asks what it grants, without having to
1076
+ * know which of the shapes to try. Every diagnosis is specific — a retired
1077
+ * string gets the retirement and what stays; a near miss in a kept family gets
1078
+ * the family's own strings; a keyed family gets its charset rule.
1079
+ */
1080
+ export function parseFunctionGrant(entry) {
1081
+ if (typeof entry !== "string" || entry.trim() === "") {
1082
+ return { error: "a capability must be a non-empty string" };
1083
+ }
1084
+ const raw = entry;
1085
+ // The exact strings first: they name no object, so nothing about them is
1086
+ // negotiable. The VERB is the key, so `databases:create` never covers
1087
+ // `databases.delete`.
1088
+ if (EXACT_GRANT_STRINGS.indexOf(raw) !== -1) {
1089
+ const at = raw.indexOf(":");
1090
+ return {
1091
+ grant: {
1092
+ family: raw.slice(0, at),
1093
+ key: raw.slice(at + 1),
1094
+ raw,
1095
+ },
1096
+ };
1097
+ }
1098
+ // A retired string is the retirement, never "unknown" and never a near
1099
+ // miss — `users:send` in the `users` family is not a typo of `users:remove`.
1100
+ const retired = retiredGrantRefusal(raw);
1101
+ if (retired)
1102
+ return { error: retired };
1103
+ const colon = raw.indexOf(":");
1104
+ const family = colon === -1 ? raw : raw.slice(0, colon);
1105
+ // A kept family that exists, asked for an operation it does not have.
1106
+ // Naming the operations it DOES have is the difference between an author
1107
+ // fixing a typo and an author concluding the family is unavailable (#3187).
1108
+ const operations = EXACT_GRANT_STRINGS.filter((entry) => entry.startsWith(`${family}:`));
1109
+ if (operations.length > 0) {
1110
+ return {
1111
+ error: `capability '${raw}' names the '${family}' family, whose only ` +
1112
+ `capabilit${operations.length > 1 ? "ies are" : "y is"} ` +
1113
+ `${operations.map((op) => `\`${op}\``).join(", ")}.`,
1114
+ };
1115
+ }
1116
+ if (KEYED_GRANT_FAMILIES.indexOf(family) !== -1) {
1117
+ const key = colon === -1 ? "" : raw.slice(colon + 1);
1118
+ const noun = KEYED_FAMILY_NOUNS[family];
1119
+ if (key === "") {
1120
+ return {
1121
+ error: `capability '${raw}' names no ${noun}: a ${family} grant is ` +
1122
+ `\`${family}:<${KEYED_FAMILY_PLACEHOLDERS[family]}>\`.`,
1123
+ };
1124
+ }
1125
+ if (key === "*") {
1126
+ return {
1127
+ error: `capability '${raw}' uses a wildcard, which no capability family ` +
1128
+ `accepts: name each ${noun} the function may reach, one grant each.`,
1129
+ };
1130
+ }
1131
+ if (!GRANT_COMPONENT_PATTERN.test(key)) {
1132
+ return {
1133
+ error: `capability '${raw}' names the ${noun} '${key}', which is not one ` +
1134
+ `component: ${KEY_CHARSET_GUIDANCE}.`,
1135
+ };
1136
+ }
1137
+ return { grant: { family: family, key, raw } };
1138
+ }
1139
+ return {
1140
+ error: `capability '${raw}' names no capability family this platform knows. ` +
1141
+ `The capabilities a function may declare are: ${ACCEPTED_FAMILIES}.`,
1142
+ };
1143
+ }
1144
+ /**
1145
+ * A whole `capabilities` list: shape, bounds, grammar, duplicates.
1146
+ *
1147
+ * The model's own caps are checked HERE rather than left to the StringSet
1148
+ * field, so an over-long entry is a named push error instead of a late model
1149
+ * throw after the R2 object has already been written (principle 6).
1150
+ */
1151
+ export function parseCapabilities(value, options = {}) {
1152
+ const empty = { capabilities: [], allGrants: [], retired: [] };
1153
+ if (value === undefined || value === null) {
1154
+ return { ok: true, ...empty, errors: [] };
1155
+ }
1156
+ if (!Array.isArray(value)) {
1157
+ return {
1158
+ ok: false,
1159
+ ...empty,
1160
+ errors: [
1161
+ "`capabilities` must be an array of capability strings, e.g. " +
1162
+ '["integration:stripe"]',
1163
+ ],
1164
+ };
1165
+ }
1166
+ const errors = [];
1167
+ const capabilities = [];
1168
+ const allGrants = [];
1169
+ const retired = [];
1170
+ const seen = new Set();
1171
+ for (const entry of value) {
1172
+ if (typeof entry !== "string") {
1173
+ errors.push(`\`capabilities\` carries a ${typeof entry} — every entry is a capability string.`);
1174
+ continue;
1175
+ }
1176
+ if (entry.length > MAX_CAPABILITY_ENTRY_LENGTH) {
1177
+ errors.push(`capability '${entry.slice(0, 40)}…' is ${entry.length} characters, ` +
1178
+ `over the ${MAX_CAPABILITY_ENTRY_LENGTH}-character limit for one grant.`);
1179
+ continue;
1180
+ }
1181
+ if (options.tolerateRetired && retiredGrantRefusal(entry)) {
1182
+ if (!retired.includes(entry))
1183
+ retired.push(entry);
1184
+ continue;
1185
+ }
1186
+ const parsed = parseFunctionGrant(entry);
1187
+ if (parsed.error || !parsed.grant) {
1188
+ errors.push(parsed.error ?? `capability '${entry}' is not a valid grant.`);
1189
+ continue;
1190
+ }
1191
+ // StringSet semantics: a repeated grant is the same grant.
1192
+ if (seen.has(entry))
1193
+ continue;
1194
+ seen.add(entry);
1195
+ capabilities.push(entry);
1196
+ allGrants.push(parsed.grant);
1197
+ }
1198
+ if (capabilities.length > MAX_CAPABILITY_ENTRIES) {
1199
+ errors.push(`\`capabilities\` declares ${capabilities.length} distinct grants, over ` +
1200
+ `the ${MAX_CAPABILITY_ENTRIES}-grant limit for one function.`);
1201
+ }
1202
+ if (errors.length > 0) {
1203
+ return { ok: false, ...empty, errors };
1204
+ }
1205
+ return { ok: true, capabilities, allGrants, retired, errors: [] };
1206
+ }
1207
+ /**
1208
+ * Grants naming an integration the app does not have.
1209
+ *
1210
+ * One message per offending grant, quoting the whole authored string AND the
1211
+ * key on its own, so an operator reading the line knows both what to fix in
1212
+ * the file and what to create in the app.
1213
+ *
1214
+ * The rule is here, beside the grammar, for the reason the whole module
1215
+ * exists: `config push`'s preflight and the authoritative server check read
1216
+ * different sources for the same facts — a `.toml` in the tree versus a row
1217
+ * in DynamoDB — and it is the RULE that must not differ between them.
1218
+ */
1219
+ export function validateKeyedGrantsAgainstTargets(grants, known) {
1220
+ const errors = [];
1221
+ for (const grant of grants) {
1222
+ if (grant.family !== "integration")
1223
+ continue;
1224
+ // `null` is a family this enforcement point could not answer for, which
1225
+ // is a deferral, not a refusal.
1226
+ if (known.integrations === null)
1227
+ continue;
1228
+ if (known.integrations.has(grant.key))
1229
+ continue;
1230
+ errors.push(`capability '${grant.raw}' names integration '${grant.key}', which ` +
1231
+ "this app does not have. A grant names an ACTIVE integration by " +
1232
+ "its key; an archived one does not count. Create the integration, " +
1233
+ "or fix the grant.");
1234
+ }
1235
+ return errors;
1236
+ }
1237
+ // ── src/config-surface/function-manifest.ts ──────────────────────────────
1238
+ /**
1239
+ * The query manifest's grammar, and the read rule it unlocks — #3187, project
1240
+ * `server-functions` phase 5.
1241
+ *
1242
+ * A pushed config version may carry a MANIFEST: the queries and mutations the
1243
+ * tree registered at module scope, collected by the CLI (`function-collect.ts`)
1244
+ * by running the tree with `primitive-functions` aliased to a recording stub.
1245
+ *
1246
+ * ── What the manifest is for ─────────────────────────────────────────────
1247
+ *
1248
+ * Observability, and nothing else. `primitive functions get` and the admin
1249
+ * get list what a version registered, so an operator can see it without
1250
+ * reading the bundle (principle 8). The read rule it used to be decided from
1251
+ * (#3187's `$caller` relaxation of `unscopedReads`) is retired by #3279:
1252
+ * function code acts as the system, so there is no per-model grant for a
1253
+ * binding to waive. The intent says so in as many words — "the models a
1254
+ * function touches are collected at push as a reviewable manifest, not an
1255
+ * authorization".
1256
+ *
1257
+ * It is HONEST-CODE evidence and the design doc says so: a hostile bundle can
1258
+ * register whatever it likes, because the collector runs the tenant's own
1259
+ * code. Nothing security-relevant at runtime reads it — parameter injection
1260
+ * and cache verification happen in the platform-owned SDK inside the isolate,
1261
+ * and the invocation gate remains the adversarial boundary.
1262
+ *
1263
+ * ── Why the grammar is here ──────────────────────────────────────────────
1264
+ *
1265
+ * Same reason as `function-grants.ts`: the CLI validates at push preflight so
1266
+ * an author sees the error against their own tree, and the server validates
1267
+ * authoritatively because the raw admin API exists. Two enforcement points,
1268
+ * one rule, in the dependency-free tree the CLI vendors.
1269
+ */
1270
+ /**
1271
+ * The manifest encoding, versioned with the envelope that carries it.
1272
+ *
1273
+ * Version 2 (#3279 behavior 10) adds what a function TOUCHES beside what it
1274
+ * registers: the deduplicated `models` its code names and the `families` of
1275
+ * `ctx.api` and the ctx helpers it reaches, collected by a static scan of the
1276
+ * built bundle, plus a `dynamicModels` marker for a model name the scan
1277
+ * could not resolve — distinct from an empty list, which means "names none".
1278
+ * A version-1 manifest still parses: it records registrations only.
1279
+ */
1280
+ export const FUNCTION_MANIFEST_SCHEMA_VERSION = 2;
1281
+ export const FUNCTION_MANIFEST_SCHEMA_VERSIONS = [1, 2];
1282
+ /** Bounds, so a manifest cannot be a way to store an unbounded blob. */
1283
+ export const MAX_MANIFEST_QUERIES = 200;
1284
+ export const MAX_MANIFEST_NAME_LENGTH = 120;
1285
+ export const MAX_MANIFEST_MODELS = 200;
1286
+ export const MAX_MANIFEST_FAMILIES = 40;
1287
+ /**
1288
+ * Validate a manifest's grammar and normalize it.
1289
+ *
1290
+ * Deliberately strict about SHAPE and silent about meaning: whether the models
1291
+ * exist, whether the queries are the ones the sandbox will really register,
1292
+ * and whether the author meant any of it are questions this cannot answer.
1293
+ */
1294
+ export function parseFunctionManifest(value) {
1295
+ const errors = [];
1296
+ const fail = (message) => ({
1297
+ ok: false,
1298
+ manifest: null,
1299
+ errors: [message],
1300
+ });
1301
+ let document = value;
1302
+ if (typeof document === "string") {
1303
+ try {
1304
+ document = JSON.parse(document);
1305
+ }
1306
+ catch (error) {
1307
+ return fail(`the manifest is not valid JSON: ${error?.message || String(error)}`);
1308
+ }
1309
+ }
1310
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
1311
+ return fail("the manifest must be an object with a `queries` array.");
1312
+ }
1313
+ const schemaVersion = Number(document.schemaVersion);
1314
+ if (!FUNCTION_MANIFEST_SCHEMA_VERSIONS.includes(schemaVersion)) {
1315
+ return fail(`the manifest declares schemaVersion ${JSON.stringify(document.schemaVersion)}; this platform reads versions ${FUNCTION_MANIFEST_SCHEMA_VERSIONS.join(" and ")}.`);
1316
+ }
1317
+ if (!Array.isArray(document.queries)) {
1318
+ return fail("the manifest's `queries` must be an array.");
1319
+ }
1320
+ if (document.queries.length > MAX_MANIFEST_QUERIES) {
1321
+ return fail(`the manifest lists ${document.queries.length} registrations, over the ` +
1322
+ `${MAX_MANIFEST_QUERIES} limit for one function.`);
1323
+ }
1324
+ const queries = [];
1325
+ const seen = new Set();
1326
+ for (const entry of document.queries) {
1327
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
1328
+ errors.push("every entry in `queries` must be an object.");
1329
+ continue;
1330
+ }
1331
+ const name = typeof entry.name === "string" ? entry.name : "";
1332
+ if (!name) {
1333
+ errors.push("a manifest entry has no `name`.");
1334
+ continue;
1335
+ }
1336
+ if (name.length > MAX_MANIFEST_NAME_LENGTH) {
1337
+ errors.push(`manifest entry '${name.slice(0, 40)}…' has a name over ` +
1338
+ `${MAX_MANIFEST_NAME_LENGTH} characters.`);
1339
+ continue;
1340
+ }
1341
+ if (seen.has(name)) {
1342
+ errors.push(`manifest entry '${name}' appears twice; every registration needs its ` +
1343
+ "own name.");
1344
+ continue;
1345
+ }
1346
+ seen.add(name);
1347
+ if (entry.kind !== "query" && entry.kind !== "mutation") {
1348
+ errors.push(`manifest entry '${name}' declares kind ${JSON.stringify(entry.kind)}; a registration is a 'query' or a 'mutation'.`);
1349
+ continue;
1350
+ }
1351
+ if (!Array.isArray(entry.models) || entry.models.some((m) => typeof m !== "string")) {
1352
+ errors.push(`manifest entry '${name}' must declare ` + "`models` as an array of strings.");
1353
+ continue;
1354
+ }
1355
+ const params = {};
1356
+ const rawParams = entry.params;
1357
+ if (rawParams !== undefined && rawParams !== null) {
1358
+ if (typeof rawParams !== "object" || Array.isArray(rawParams)) {
1359
+ errors.push(`manifest entry '${name}' must declare ` + "`params` as an object.");
1360
+ continue;
1361
+ }
1362
+ for (const [key, spec] of Object.entries(rawParams)) {
1363
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
1364
+ errors.push(`manifest entry '${name}' declares parameter '${key}' as ` +
1365
+ "something other than an object.");
1366
+ continue;
1367
+ }
1368
+ params[key] = {
1369
+ ...(spec.type !== undefined ? { type: String(spec.type) } : {}),
1370
+ ...(spec.caller === true ? { caller: true } : {}),
1371
+ ...(spec.optional === true ? { optional: true } : {}),
1372
+ ...(Object.prototype.hasOwnProperty.call(spec, "default")
1373
+ ? { default: spec.default }
1374
+ : {}),
1375
+ };
1376
+ }
1377
+ }
1378
+ let cache = null;
1379
+ if (entry.cache !== undefined && entry.cache !== null) {
1380
+ const ttl = Number(entry.cache.ttlMs);
1381
+ if (!Number.isFinite(ttl) || ttl <= 0) {
1382
+ errors.push(`manifest entry '${name}' declares a cache with no positive ` +
1383
+ "`ttlMs`.");
1384
+ continue;
1385
+ }
1386
+ cache = { ttlMs: ttl };
1387
+ }
1388
+ queries.push({
1389
+ name,
1390
+ kind: entry.kind,
1391
+ models: entry.models.map(String),
1392
+ params,
1393
+ cache,
1394
+ // Derived, never trusted from the entry: a caller-scoped registration is
1395
+ // one with a `$caller` parameter, and that is visible in `params`.
1396
+ callerScoped: Object.values(params).some((p) => p.caller === true),
1397
+ });
1398
+ }
1399
+ // Version 2's touched-surface lists. Bounded and normalized like the
1400
+ // queries; a version-1 document has none and reads as "records
1401
+ // registrations only".
1402
+ const names = (field, max) => {
1403
+ const raw = document[field];
1404
+ if (raw === undefined || raw === null)
1405
+ return [];
1406
+ if (!Array.isArray(raw) || raw.some((m) => typeof m !== "string")) {
1407
+ errors.push(`the manifest's \`${field}\` must be an array of strings.`);
1408
+ return null;
1409
+ }
1410
+ const list = [...new Set(raw.map(String))].sort();
1411
+ if (list.length > max) {
1412
+ errors.push(`the manifest lists ${list.length} ${field}, over the ${max} limit for one function.`);
1413
+ return null;
1414
+ }
1415
+ for (const name of list) {
1416
+ if (name === "" || name.length > MAX_MANIFEST_NAME_LENGTH) {
1417
+ errors.push(`the manifest's \`${field}\` carries '${name.slice(0, 40)}…', which is empty or over ` +
1418
+ `${MAX_MANIFEST_NAME_LENGTH} characters.`);
1419
+ return null;
1420
+ }
1421
+ }
1422
+ return list;
1423
+ };
1424
+ const models = schemaVersion >= 2 ? names("models", MAX_MANIFEST_MODELS) : [];
1425
+ const families = schemaVersion >= 2 ? names("families", MAX_MANIFEST_FAMILIES) : [];
1426
+ if (schemaVersion >= 2 && document.dynamicModels !== undefined && typeof document.dynamicModels !== "boolean") {
1427
+ errors.push("the manifest's `dynamicModels` must be a boolean.");
1428
+ }
1429
+ if (errors.length > 0)
1430
+ return { ok: false, manifest: null, errors };
1431
+ queries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1432
+ return {
1433
+ ok: true,
1434
+ manifest: {
1435
+ schemaVersion,
1436
+ queries,
1437
+ models: models ?? [],
1438
+ families: families ?? [],
1439
+ dynamicModels: schemaVersion >= 2 && document.dynamicModels === true,
1440
+ },
1441
+ errors: [],
1442
+ };
1443
+ }
814
1444
  // ── src/config-surface/workflow.ts ───────────────────────────────────────
815
1445
  /**
816
1446
  * The `workflow` configuration object's definition (issue #2644, phase 1).
@@ -3171,18 +3801,23 @@ export const TRANSFORM_SURFACE = {
3171
3801
  * possible (a table is addressed by its path) and would in any case claim the
3172
3802
  * version is a table an author writes, which is exactly what F-002 removed.
3173
3803
  *
3174
- * ── The phase-1 key set is narrower than the models ──────────────────────
3175
- *
3176
- * `capabilities` (#3182/#3183) is declared on the models now so that child
3177
- * touches only the surface, and is deliberately NOT accepted here: a file
3178
- * carrying it today is rejected as an unknown key rather than accepted and
3179
- * dropped, which is the silent-200 failure the whole registry exists to end.
3804
+ * ── The key set is still narrower than the models ────────────────────────
3180
3805
  *
3181
3806
  * The trigger blocks joined the accepted set with #3181. They are `structural`
3182
3807
  * for the same reason `entry` is: they are authored, they are not fields of
3183
3808
  * the header model, and they travel inside the version envelope's TOML bytes
3184
3809
  * rather than as a payload field of their own.
3185
3810
  *
3811
+ * `capabilities` joined the accepted set with #3182 (with `unscopedReads`,
3812
+ * which #3279 retired together with `runAs` and the per-model grammar: the
3813
+ * code acts as the system, and a capability is declared only where it
3814
+ * configures something). It is version-carried and DERIVED by the server from
3815
+ * the TOML inside the pushed envelope rather than sent as a payload field of
3816
+ * its own — the file a human reviews on pull has to be the same statement the
3817
+ * platform enforces (D3182-002). It is listed in `tomlOnlyKeys` for that
3818
+ * reason: it is authored, and it travels as envelope bytes. The two retired
3819
+ * keys carry their guidance in `retired-keys.ts` under this table's prefix.
3820
+ *
3186
3821
  * ── `status` ─────────────────────────────────────────────────────────────
3187
3822
  *
3188
3823
  * A server function is the SIXTH status-carrying type. `status` is server-owned
@@ -3208,16 +3843,6 @@ export const SERVER_FUNCTION_SURFACE = {
3208
3843
  writableOn: BOTH,
3209
3844
  validation: "passthrough",
3210
3845
  },
3211
- {
3212
- field: "runAs",
3213
- tomlKey: "runAs",
3214
- type: "string",
3215
- emit: "always",
3216
- writableOn: BOTH,
3217
- // `caller | system` — the identity the function's own API calls run
3218
- // as (the intent, "Who may invoke a function, and as whom?").
3219
- validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController"),
3220
- },
3221
3846
  {
3222
3847
  field: "access",
3223
3848
  tomlKey: "access",
@@ -3280,6 +3905,21 @@ export const SERVER_FUNCTION_SURFACE = {
3280
3905
  "Repointed by a config-version push; versions are nameless, so " +
3281
3906
  "there is no `activeConfigName` for an author to write.",
3282
3907
  },
3908
+ runAs: {
3909
+ kind: "server-owned",
3910
+ note: "RETIRED from the write surface by #3279: function code acts as " +
3911
+ "the system whoever invoked it, so the mode the column chose " +
3912
+ "between no longer exists. Declared-and-deprecated storage only " +
3913
+ "until phase 6 drops the column; a stored value decides nothing " +
3914
+ "and is never echoed.",
3915
+ },
3916
+ deleteLockedAt: {
3917
+ kind: "server-owned",
3918
+ note: "#3186 — the lease a hard delete takes before it scans for live " +
3919
+ "runs, so that a start already in flight either shows up in that " +
3920
+ "scan or sees the lease. Held for minutes at a time by one " +
3921
+ "request, never authored and never pulled.",
3922
+ },
3283
3923
  createdBy: {
3284
3924
  kind: "server-owned",
3285
3925
  note: "Admin who created the function; assigned server-side.",
@@ -3329,6 +3969,17 @@ export const SERVER_FUNCTION_SURFACE = {
3329
3969
  "behavior is covered by `envelopeHash` and cannot disagree with " +
3330
3970
  "the file even for a direct API caller (D3181-005).",
3331
3971
  },
3972
+ capabilities: {
3973
+ kind: "structural",
3974
+ note: "What this version may CONFIGURE (#3279): `integration:<key>` " +
3975
+ "(the egress allowlist), `secret:<NAME>` (which secret values may " +
3976
+ "enter the sandbox) and the high-blast-radius opt-ins such as " +
3977
+ "`databases:delete` or `users:setRole`. Never a statement about " +
3978
+ "data — function code acts as the system. Stored on the config " +
3979
+ "version and DERIVED by the server from these authored bytes, so " +
3980
+ "the file a human reviews is the authority — a push cannot store a " +
3981
+ "capability the TOML does not declare.",
3982
+ },
3332
3983
  },
3333
3984
  responseOnlyKeys: {},
3334
3985
  },
@@ -3464,7 +4115,7 @@ export const TEST_CASE_SURFACE = {
3464
4115
  requestModes: ["create", "update"],
3465
4116
  note: "The sidecar's file name (basename without `.toml`) — the case's " +
3466
4117
  "identity in the committed tree (#2896). It travels in the BODY " +
3467
- "so a clean checkout with no `.primitive-sync.json` reconciles " +
4118
+ "so a clean checkout with no `.sync-state.json` reconciles " +
3468
4119
  "instead of creating a duplicate of every case; create stamps it " +
3469
4120
  "and update backfills it onto a legacy case. Never inside the " +
3470
4121
  "`[test]` table: sidecar bytes and the content hash are unchanged " +
@@ -4313,6 +4964,7 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
4313
4964
  "access",
4314
4965
  "inputSchema",
4315
4966
  "outputSchema",
4967
+ "deleteLockedAt",
4316
4968
  "createdBy",
4317
4969
  "createdAt",
4318
4970
  "modifiedAt",