primitive-admin 1.1.0-alpha.75 → 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 (40) hide show
  1. package/dist/bin/primitive.js +98 -10
  2. package/dist/bin/primitive.js.map +1 -1
  3. package/dist/src/commands/functions.js +53 -18
  4. package/dist/src/commands/functions.js.map +1 -1
  5. package/dist/src/commands/prompts.js +54 -39
  6. package/dist/src/commands/prompts.js.map +1 -1
  7. package/dist/src/commands/sync.d.ts +39 -0
  8. package/dist/src/commands/sync.js +163 -90
  9. package/dist/src/commands/sync.js.map +1 -1
  10. package/dist/src/commands/workflows.js +61 -30
  11. package/dist/src/commands/workflows.js.map +1 -1
  12. package/dist/src/lib/api-client.d.ts +16 -1
  13. package/dist/src/lib/api-client.js +16 -3
  14. package/dist/src/lib/api-client.js.map +1 -1
  15. package/dist/src/lib/block-selector.d.ts +58 -0
  16. package/dist/src/lib/block-selector.js +92 -0
  17. package/dist/src/lib/block-selector.js.map +1 -0
  18. package/dist/src/lib/config-object-descriptor.js +1 -3
  19. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  20. package/dist/src/lib/function-collect.d.ts +29 -7
  21. package/dist/src/lib/function-collect.js +135 -11
  22. package/dist/src/lib/function-collect.js.map +1 -1
  23. package/dist/src/lib/function-grants-preflight.d.ts +34 -88
  24. package/dist/src/lib/function-grants-preflight.js +32 -145
  25. package/dist/src/lib/function-grants-preflight.js.map +1 -1
  26. package/dist/src/lib/function-sync.d.ts +3 -14
  27. package/dist/src/lib/function-sync.js +10 -49
  28. package/dist/src/lib/function-sync.js.map +1 -1
  29. package/dist/src/lib/function-triggers.d.ts +6 -0
  30. package/dist/src/lib/function-triggers.js +63 -4
  31. package/dist/src/lib/function-triggers.js.map +1 -1
  32. package/dist/src/lib/generated-config-surfaces.d.ts +188 -236
  33. package/dist/src/lib/generated-config-surfaces.js +355 -453
  34. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  35. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  36. package/dist/src/lib/generated-sdk-types.js +1 -1
  37. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  38. package/dist/src/lib/toml-database-config.js +19 -2
  39. package/dist/src/lib/toml-database-config.js.map +1 -1
  40. package/package.json +1 -1
@@ -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
@@ -813,39 +842,47 @@ export function retiredConfigKey(prefix, key) {
813
842
  }
814
843
  // ── src/config-surface/function-grants.ts ────────────────────────────────
815
844
  /**
816
- * The database capability grammar for server functions (#3182, project
817
- * `server-functions` phase 2 of the intent, phase 1 of this child).
845
+ * The capability grammar for server functions — #3182 phase 1, rewritten by
846
+ * #3279 (project `server-functions` phase 3).
818
847
  *
819
- * A function declares what it may reach in its own TOML:
848
+ * A function declares in its own TOML what it may CONFIGURE:
820
849
  *
821
- * capabilities = ["database:orders/order:read", "database:orders/order:write"]
822
- * unscopedReads = true
850
+ * capabilities = ["integration:stripe", "secret:STRIPE_KEY", "databases:delete"]
823
851
  *
824
- * ── Why the components have a charset ────────────────────────────────────
852
+ * ── Why the grammar is this small ────────────────────────────────────────
825
853
  *
826
- * The intent decides ("What does the database name in a grant refer to?") that
827
- * a grant names the database TYPE key, and the design fixes the string as
828
- * `database:<databaseType>/<modelName>:read|write` with no wildcards. That
829
- * string has to be INJECTIVE — one string, one (type, model, access) triple —
830
- * or a grant means two things at once. It is not injective for the identifiers
831
- * the repository accepts today: `database-type-config-controller.ts` forbids
832
- * only `#` in a type key, and js-bao's TOML loader preserves model names
833
- * verbatim, so `(type "a", model "b/c")` and `(type "a/b", model "c")` both
834
- * spell `database:a/b/c:read` (D3182-001).
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:
835
860
  *
836
- * The fix is a charset rather than an escaping scheme: both components must
837
- * match `[A-Za-z0-9_-]+`, so neither delimiter can enter a component and the
838
- * grammar is injective by construction. Authored TOML stays readable, and a
839
- * database type whose key carries a delimiter is simply UNREACHABLE from
840
- * functions — fail closed, with an error that says why rather than a grant
841
- * that quietly covers a neighbour.
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).
842
879
  *
843
880
  * ── Why this module is pure ──────────────────────────────────────────────
844
881
  *
845
- * Grants are validated twice — by `config push`'s preflight, so an author sees
846
- * the error against their own file, and by the server, which is authoritative
847
- * because the raw admin API exists. Two enforcement points must not be two
848
- * grammars, so the grammar lives here, in the dependency-free
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
849
886
  * `src/config-surface/` tree the CLI vendors at build time
850
887
  * (`cli/scripts/gen-config-surfaces.mjs`). The server imports this module; the
851
888
  * CLI imports the generated copy; the drift guard fails if they differ.
@@ -855,145 +892,220 @@ export const GRANT_COMPONENT_PATTERN = /^[A-Za-z0-9_-]+$/;
855
892
  /** `ServerFunctionConfig.capabilities` is a StringSet with these bounds. */
856
893
  export const MAX_CAPABILITY_ENTRIES = 100;
857
894
  export const MAX_CAPABILITY_ENTRY_LENGTH = 200;
858
- /** The whole grammar, in one expression. */
859
- export const DATABASE_GRANT_PATTERN = /^database:([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+):(read|write)$/;
860
895
  /**
861
- * Capability families this child does not own, and the child that does. A
862
- * function author who writes one gets told where it is going rather than "not
863
- * a valid grant", which reads as "never".
864
- *
865
- * #3183 took `integration`, `prompt` and `secret` off this list by shipping
866
- * them. `channel` stays reserved.
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}$`.
867
900
  */
868
- const FAMILY_OWNERS = {
869
- channel: "#3184 (channels and database-change triggers)",
870
- };
901
+ export const KEYED_GRANT_FAMILIES = ["integration", "secret"];
871
902
  /**
872
- * The families a grant may name, in the order the guidance lists them.
903
+ * The high-blast-radius opt-ins — #3279, criterion 5 (CR3279-001, D3279-003).
873
904
  *
874
- * Two shapes, not one:
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.
875
912
  *
876
- * KEYED `integration:<key>`, `prompt:<key>`, `secret:<NAME>`, `var:<NAME>`
877
- * — one component under {@link GRANT_COMPONENT_PATTERN}, naming a
878
- * single object. Every key format the platform issues fits: an
879
- * integration or prompt key is `^[a-z0-9][a-z0-9-_]{2,}$` and a
880
- * secret or config-var name is `^[A-Z][A-Z0-9_]{0,63}$`.
881
- * EXACT `users:send`, `connections:send` — no key component at all. The
882
- * design doc spells them this way, and the capability really is
883
- * app-wide: a send names its target at CALL time, so there is no
884
- * object for the grant to name.
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.
885
917
  *
886
- * `database:` keeps its own two-component grammar, unchanged.
887
- *
888
- * `var:` is this child's choice (D3183-001). The intent is silent on config
889
- * vars; the family is named after the `{{vars.*}}` template namespace the
890
- * integration proxy already resolves, so an author who has written
891
- * `{{vars.REGION}}` reads `var:REGION` without learning a second word for the
892
- * same thing.
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`).
893
923
  */
894
- export const KEYED_GRANT_FAMILIES = [
895
- "integration",
896
- "prompt",
897
- "secret",
898
- "var",
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",
899
936
  ];
937
+ /** The exact strings, which since #3279 are exactly the high-blast list. */
938
+ export const EXACT_GRANT_STRINGS = HIGH_BLAST_CAPABILITIES;
900
939
  /**
901
- * Whole platform capabilities — #3187, project `server-functions` phase 4.
902
- *
903
- * The EXACT shape, for the same reason the sends take it: neither names an
904
- * object. An app has ONE hourly email budget and ONE analytics dataset, so a
905
- * grant carrying a target would be a promise the enforcement point could not
906
- * keep.
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.
907
950
  *
908
- * Both gate a function-principal-only route (`emails/send`,
909
- * `analytics/write-for-user`) rather than a family the gateway passes a
910
- * caller's own authority through. `email.send` has no HTTP route for a member
911
- * to call at all, and `analytics.writeForUser` is system-only by design
912
- * (D3187-SO-001) — caller-mode family admission cannot reach either, which is
913
- * why they are grants and not passthrough.
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.
914
955
  */
915
- export const PLATFORM_CAPABILITIES = [
916
- "email:send",
917
- "analytics:writeForUser",
918
- ];
919
- export const EXACT_GRANT_STRINGS = [
920
- "users:send",
921
- "connections:send",
922
- ...PLATFORM_CAPABILITIES,
923
- ];
924
- /** The operations one exact family offers, for a refusal to name them. */
925
- function exactFamilyOperations(family) {
926
- return EXACT_GRANT_STRINGS.filter((entry) => entry.startsWith(`${family}:`));
927
- }
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
+ };
928
978
  /**
929
- * The grant string for a (type, model, access) triple, or null when either
930
- * identifier is outside the charset — which is the whole of "unreachable from
931
- * functions". Callers that hold real identifiers (the enforcement path, the
932
- * typed-handle emitter) use this rather than string concatenation, so no code
933
- * path can mint a string this module would not parse back to the same triple.
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.
934
985
  */
935
- export function formatDatabaseGrant(databaseType, modelName, access) {
936
- if (!GRANT_COMPONENT_PATTERN.test(String(databaseType ?? "")))
937
- return null;
938
- if (!GRANT_COMPONENT_PATTERN.test(String(modelName ?? "")))
939
- return null;
940
- if (access !== "read" && access !== "write")
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)
941
992
  return null;
942
- return `database:${databaseType}/${modelName}:${access}`;
993
+ return (`capability '${raw}' is retired (#3279): function code acts as the ` +
994
+ `system, so ${reason}. Delete the entry. ${KEPT_KINDS}`);
943
995
  }
944
- /** The charset rule, as one sentence an author can act on. */
945
- const CHARSET_GUIDANCE = "each component must match [A-Za-z0-9_-]+ so the grant string means exactly " +
946
- "one (type, model) pair; a database type or model whose name carries '/' or " +
947
- "':' is unreachable from functions and needs renaming to be granted";
948
996
  /** The keyed families' charset rule, as one sentence an author can act on. */
949
997
  const KEY_CHARSET_GUIDANCE = "the key must match [A-Za-z0-9_-]+ so the grant string names exactly one " +
950
998
  "object; a key carrying ':' or '/' is unreachable from functions and needs " +
951
999
  "renaming to be granted";
952
- /** Every accepted family, in one sentence, for the unknown-family error. */
953
- const ACCEPTED_FAMILIES = "`database:<databaseType>/<modelName>:read|write`, " +
954
- "`integration:<integrationKey>`, `prompt:<promptKey>`, " +
955
- "`secret:<NAME>`, `var:<NAME>`, `users:send`, `connections:send`, " +
956
- "`email:send`, `analytics:writeForUser`";
957
1000
  /**
958
- * One capability entry as ANY grant, or the reason it is not one.
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.
959
1073
  *
960
1074
  * The single entry point the enforcement path and both preflights use: a
961
1075
  * caller holds one authored string and asks what it grants, without having to
962
- * know which of the families' shapes to try. Every diagnosis is
963
- * family-specific — an author who mistypes an access verb should not be told
964
- * to read a charset rule, and an author who hits the charset rule should be
965
- * told that the identifier, not their typing, is the problem.
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.
966
1079
  */
967
1080
  export function parseFunctionGrant(entry) {
968
1081
  if (typeof entry !== "string" || entry.trim() === "") {
969
1082
  return { error: "a capability must be a non-empty string" };
970
1083
  }
971
1084
  const raw = entry;
972
- // The exact strings first: they have no key component, so nothing about
973
- // them is negotiable and a near-miss deserves its own message.
974
- if (raw === "users:send")
975
- return { grant: { family: "users", key: "", raw } };
976
- if (raw === "connections:send") {
977
- return { grant: { family: "connections", key: "", raw } };
978
- }
979
- if (raw === "email:send")
980
- return { grant: { family: "email", key: "", raw } };
981
- if (raw === "analytics:writeForUser") {
982
- return { grant: { family: "analytics", key: "", raw } };
983
- }
984
- const colon = raw.indexOf(":");
985
- const family = colon === -1 ? raw : raw.slice(0, colon);
986
- if (family === "users" || family === "connections") {
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(":");
987
1090
  return {
988
- error: `capability '${raw}' is not a send grant: the '${family}' family has ` +
989
- `exactly one capability, \`${family}:send\`, with no key component — a ` +
990
- "send names its target when it is called, not when it is granted.",
1091
+ grant: {
1092
+ family: raw.slice(0, at),
1093
+ key: raw.slice(at + 1),
1094
+ raw,
1095
+ },
991
1096
  };
992
1097
  }
993
- // A platform family that exists, asked for an operation it does not have.
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.
994
1106
  // Naming the operations it DOES have is the difference between an author
995
1107
  // fixing a typo and an author concluding the family is unavailable (#3187).
996
- const operations = exactFamilyOperations(family);
1108
+ const operations = EXACT_GRANT_STRINGS.filter((entry) => entry.startsWith(`${family}:`));
997
1109
  if (operations.length > 0) {
998
1110
  return {
999
1111
  error: `capability '${raw}' names the '${family}' family, whose only ` +
@@ -1003,15 +1115,11 @@ export function parseFunctionGrant(entry) {
1003
1115
  }
1004
1116
  if (KEYED_GRANT_FAMILIES.indexOf(family) !== -1) {
1005
1117
  const key = colon === -1 ? "" : raw.slice(colon + 1);
1006
- const noun = family === "integration" || family === "prompt"
1007
- ? `${family} key`
1008
- : family === "secret"
1009
- ? "secret name"
1010
- : "config-var name";
1118
+ const noun = KEYED_FAMILY_NOUNS[family];
1011
1119
  if (key === "") {
1012
1120
  return {
1013
1121
  error: `capability '${raw}' names no ${noun}: a ${family} grant is ` +
1014
- `\`${family}:<${family === "secret" || family === "var" ? "NAME" : "key"}>\`.`,
1122
+ `\`${family}:<${KEYED_FAMILY_PLACEHOLDERS[family]}>\`.`,
1015
1123
  };
1016
1124
  }
1017
1125
  if (key === "*") {
@@ -1028,89 +1136,9 @@ export function parseFunctionGrant(entry) {
1028
1136
  }
1029
1137
  return { grant: { family: family, key, raw } };
1030
1138
  }
1031
- if (family === "database") {
1032
- const parsed = parseDatabaseGrant(raw);
1033
- if (parsed.error || !parsed.grant) {
1034
- return { error: parsed.error ?? `capability '${raw}' is not a valid grant.` };
1035
- }
1036
- return {
1037
- grant: {
1038
- family: "database",
1039
- key: "",
1040
- databaseType: parsed.grant.databaseType,
1041
- modelName: parsed.grant.modelName,
1042
- access: parsed.grant.access,
1043
- raw,
1044
- },
1045
- };
1046
- }
1047
- const owner = FAMILY_OWNERS[family];
1048
- if (owner) {
1049
- return {
1050
- error: `capability '${raw}' names the '${family}' family, which server ` +
1051
- `functions do not accept yet — it ships with ${owner}.`,
1052
- };
1053
- }
1054
1139
  return {
1055
1140
  error: `capability '${raw}' names no capability family this platform knows. ` +
1056
- `The families a function may declare are: ${ACCEPTED_FAMILIES}.`,
1057
- };
1058
- }
1059
- /**
1060
- * One capability entry as a DATABASE grant, or the reason it is not one.
1061
- *
1062
- * Kept as its own function because the `database:` grammar has two components
1063
- * and an access verb, and saying which of the three went wrong is most of
1064
- * what makes the error useful.
1065
- */
1066
- export function parseDatabaseGrant(entry) {
1067
- if (typeof entry !== "string" || entry.trim() === "") {
1068
- return { error: "a capability must be a non-empty string" };
1069
- }
1070
- const raw = entry;
1071
- const match = DATABASE_GRANT_PATTERN.exec(raw);
1072
- if (match) {
1073
- return {
1074
- grant: {
1075
- databaseType: match[1],
1076
- modelName: match[2],
1077
- access: match[3],
1078
- raw,
1079
- },
1080
- };
1081
- }
1082
- if (!raw.startsWith("database:")) {
1083
- return {
1084
- error: `capability '${raw}' is not a database grant ` +
1085
- "(`database:<databaseType>/<modelName>:read|write`).",
1086
- };
1087
- }
1088
- // A `database:` capability that did not match. Say which part failed.
1089
- const rest = raw.slice("database:".length);
1090
- const lastColon = rest.lastIndexOf(":");
1091
- if (lastColon === -1) {
1092
- return {
1093
- error: `capability '${raw}' is missing its access verb: a database grant ends ` +
1094
- "in `:read` or `:write`.",
1095
- };
1096
- }
1097
- const pair = rest.slice(0, lastColon);
1098
- const access = rest.slice(lastColon + 1);
1099
- if (access !== "read" && access !== "write") {
1100
- return {
1101
- error: `capability '${raw}' names access '${access}': a database grant ends in ` +
1102
- "`:read` or `:write` (matched exactly — no other verb, no wildcard).",
1103
- };
1104
- }
1105
- const slash = pair.indexOf("/");
1106
- if (slash === -1) {
1107
- return {
1108
- error: `capability '${raw}' names no model: a database grant names a type and ` +
1109
- "a model as `<databaseType>/<modelName>`.",
1110
- };
1111
- }
1112
- return {
1113
- error: `capability '${raw}' does not name one type/model pair: ${CHARSET_GUIDANCE}.`,
1141
+ `The capabilities a function may declare are: ${ACCEPTED_FAMILIES}.`,
1114
1142
  };
1115
1143
  }
1116
1144
  /**
@@ -1120,14 +1148,8 @@ export function parseDatabaseGrant(entry) {
1120
1148
  * field, so an over-long entry is a named push error instead of a late model
1121
1149
  * throw after the R2 object has already been written (principle 6).
1122
1150
  */
1123
- export function parseCapabilities(value) {
1124
- const empty = {
1125
- capabilities: [],
1126
- grants: [],
1127
- allGrants: [],
1128
- platformGrants: [],
1129
- hasReadGrant: false,
1130
- };
1151
+ export function parseCapabilities(value, options = {}) {
1152
+ const empty = { capabilities: [], allGrants: [], retired: [] };
1131
1153
  if (value === undefined || value === null) {
1132
1154
  return { ok: true, ...empty, errors: [] };
1133
1155
  }
@@ -1136,19 +1158,19 @@ export function parseCapabilities(value) {
1136
1158
  ok: false,
1137
1159
  ...empty,
1138
1160
  errors: [
1139
- "`capabilities` must be an array of grant strings, e.g. " +
1140
- '["database:orders/order:read"]',
1161
+ "`capabilities` must be an array of capability strings, e.g. " +
1162
+ '["integration:stripe"]',
1141
1163
  ],
1142
1164
  };
1143
1165
  }
1144
1166
  const errors = [];
1145
1167
  const capabilities = [];
1146
- const grants = [];
1147
1168
  const allGrants = [];
1169
+ const retired = [];
1148
1170
  const seen = new Set();
1149
1171
  for (const entry of value) {
1150
1172
  if (typeof entry !== "string") {
1151
- errors.push(`\`capabilities\` carries a ${typeof entry} — every entry is a grant string.`);
1173
+ errors.push(`\`capabilities\` carries a ${typeof entry} — every entry is a capability string.`);
1152
1174
  continue;
1153
1175
  }
1154
1176
  if (entry.length > MAX_CAPABILITY_ENTRY_LENGTH) {
@@ -1156,6 +1178,11 @@ export function parseCapabilities(value) {
1156
1178
  `over the ${MAX_CAPABILITY_ENTRY_LENGTH}-character limit for one grant.`);
1157
1179
  continue;
1158
1180
  }
1181
+ if (options.tolerateRetired && retiredGrantRefusal(entry)) {
1182
+ if (!retired.includes(entry))
1183
+ retired.push(entry);
1184
+ continue;
1185
+ }
1159
1186
  const parsed = parseFunctionGrant(entry);
1160
1187
  if (parsed.error || !parsed.grant) {
1161
1188
  errors.push(parsed.error ?? `capability '${entry}' is not a valid grant.`);
@@ -1167,14 +1194,6 @@ export function parseCapabilities(value) {
1167
1194
  seen.add(entry);
1168
1195
  capabilities.push(entry);
1169
1196
  allGrants.push(parsed.grant);
1170
- if (parsed.grant.family === "database") {
1171
- grants.push({
1172
- databaseType: String(parsed.grant.databaseType),
1173
- modelName: String(parsed.grant.modelName),
1174
- access: parsed.grant.access,
1175
- raw: parsed.grant.raw,
1176
- });
1177
- }
1178
1197
  }
1179
1198
  if (capabilities.length > MAX_CAPABILITY_ENTRIES) {
1180
1199
  errors.push(`\`capabilities\` declares ${capabilities.length} distinct grants, over ` +
@@ -1183,90 +1202,10 @@ export function parseCapabilities(value) {
1183
1202
  if (errors.length > 0) {
1184
1203
  return { ok: false, ...empty, errors };
1185
1204
  }
1186
- return {
1187
- ok: true,
1188
- capabilities,
1189
- grants,
1190
- allGrants,
1191
- platformGrants: allGrants
1192
- .filter((g) => PLATFORM_CAPABILITIES.includes(g.raw))
1193
- .map((g) => g.raw),
1194
- hasReadGrant: grants.some((g) => g.access === "read"),
1195
- errors: [],
1196
- };
1197
- }
1198
- /**
1199
- * The unscoped-read rule, from the intent's database-authorization decision:
1200
- * "a member-accessible function whose database read has no `$caller` binding
1201
- * needs an explicit unscoped-read flag in its config, and that flag is the
1202
- * human-reviewed artifact. From phase 2, any member-reachable function holding
1203
- * a database read grant carries the flag."
1204
- *
1205
- * Every HTTP-invocable function is member-reachable behind its access gate, and
1206
- * in this phase every function is HTTP-invocable, so the rule applies to every
1207
- * config declaring a read grant — a fail-closed superset of the intent's rule,
1208
- * which a later child may narrow for trigger-only functions.
1209
- */
1210
- export function unscopedReadsRefusal(grants, unscopedReads) {
1211
- if (unscopedReads)
1212
- return null;
1213
- const reads = grants.filter((g) => g.access === "read");
1214
- if (reads.length === 0)
1215
- return null;
1216
- return (`declares the database read grant${reads.length > 1 ? "s" : ""} ` +
1217
- `${reads.map((g) => `'${g.raw}'`).join(", ")} but not \`unscopedReads = true\`. ` +
1218
- "A function's reads are NOT scoped to its caller until `$caller` bindings " +
1219
- "arrive, so a read grant reads every row of that model for every caller. " +
1220
- "The flag is the human-reviewed artifact that says that was intended: add " +
1221
- "`unscopedReads = true` to the `[function]` table, or drop the read grant.");
1222
- }
1223
- /**
1224
- * What a grant may name: a database type the app has, and — when that type
1225
- * declares a schema — a model the schema declares.
1226
- *
1227
- * `models: null` is a type with NO schema. The DatabaseDO is schemaless, so a
1228
- * grant on such a type is accepted by grammar and simply carries no typed
1229
- * handle; refusing it would make an untyped database ungrantable.
1230
- *
1231
- * `schemaUnreadable` is the opposite case and fails CLOSED: the type declares a
1232
- * schema, the platform could not evaluate the grant against it, and an
1233
- * authorization question that could not be evaluated is answered no.
1234
- *
1235
- * The map is supplied by the caller because the two enforcement points read
1236
- * different sources for the same facts — the CLI reads the config tree's
1237
- * `database-type-configs/*.toml`, the server reads the app's rows — and the
1238
- * RULE is what must not differ.
1239
- */
1240
- export function validateGrantsAgainstTypes(grants, types) {
1241
- const errors = [];
1242
- for (const grant of grants) {
1243
- const type = types.get(grant.databaseType);
1244
- if (type && type.schemaUnreadable) {
1245
- errors.push(`capability '${grant.raw}' names database type '${grant.databaseType}', ` +
1246
- "whose schema could not be parsed, so there is no way to check that " +
1247
- `it declares model '${grant.modelName}'. A grant is refused rather ` +
1248
- "than guessed: fix the type's schema (or clear it, which makes the " +
1249
- "type schemaless and grantable) and push again.");
1250
- continue;
1251
- }
1252
- if (!type) {
1253
- errors.push(`capability '${grant.raw}' names database type '${grant.databaseType}', ` +
1254
- "which this app does not have. A grant names a database TYPE key (it " +
1255
- "covers every database of that type); create the type, or fix the grant.");
1256
- continue;
1257
- }
1258
- if (type.models && !type.models.has(grant.modelName)) {
1259
- const known = [...type.models].sort().slice(0, 8).join(", ");
1260
- errors.push(`capability '${grant.raw}' names model '${grant.modelName}', which the ` +
1261
- `schema of database type '${grant.databaseType}' does not declare` +
1262
- (known ? ` (it declares: ${known})` : "") +
1263
- ".");
1264
- }
1265
- }
1266
- return errors;
1205
+ return { ok: true, capabilities, allGrants, retired, errors: [] };
1267
1206
  }
1268
1207
  /**
1269
- * Grants naming an integration or prompt the app does not have.
1208
+ * Grants naming an integration the app does not have.
1270
1209
  *
1271
1210
  * One message per offending grant, quoting the whole authored string AND the
1272
1211
  * key on its own, so an operator reading the line knows both what to fix in
@@ -1280,20 +1219,17 @@ export function validateGrantsAgainstTypes(grants, types) {
1280
1219
  export function validateKeyedGrantsAgainstTargets(grants, known) {
1281
1220
  const errors = [];
1282
1221
  for (const grant of grants) {
1283
- const known_ = grant.family === "integration"
1284
- ? known.integrations
1285
- : grant.family === "prompt"
1286
- ? known.prompts
1287
- : undefined;
1288
- // `undefined` is a family with no target check; `null` is one this
1289
- // enforcement point could not answer for. Neither is a refusal.
1290
- if (known_ === undefined || known_ === null)
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)
1291
1227
  continue;
1292
- if (known_.has(grant.key))
1228
+ if (known.integrations.has(grant.key))
1293
1229
  continue;
1294
- errors.push(`capability '${grant.raw}' names ${grant.family} '${grant.key}', which ` +
1295
- `this app does not have. A grant names an ACTIVE ${grant.family} by ` +
1296
- `its key; an archived one does not count. Create the ${grant.family}, ` +
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, " +
1297
1233
  "or fix the grant.");
1298
1234
  }
1299
1235
  return errors;
@@ -1309,23 +1245,20 @@ export function validateKeyedGrantsAgainstTargets(grants, known) {
1309
1245
  *
1310
1246
  * ── What the manifest is for ─────────────────────────────────────────────
1311
1247
  *
1312
- * Two things, and neither is authorization at runtime:
1313
- *
1314
- * 1. **The read rule.** The intent's database-authorization decision says
1315
- * "from phase 4 a recorded `$caller` binding on every read satisfies the
1316
- * [unscoped-read] rule instead". That decision is made ONCE, at push, by
1317
- * a human-reviewable statement — which is exactly what a manifest is.
1318
- * 2. **Observability.** `primitive functions get` and the admin get list
1319
- * what a version registered, so an operator can see it without reading
1320
- * the bundle (principle 8).
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".
1321
1256
  *
1322
1257
  * It is HONEST-CODE evidence and the design doc says so: a hostile bundle can
1323
1258
  * register whatever it likes, because the collector runs the tenant's own
1324
- * code. Nothing security-relevant at runtime reads it — parameter injection,
1325
- * the raw-read refusal and cache verification all happen in the platform-owned
1326
- * SDK inside the isolate, and the grants remain the adversarial boundary. What
1327
- * the manifest buys is protection against BUGS, which is the bound the design
1328
- * doc states and this comment repeats so nobody has to rediscover it.
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.
1329
1262
  *
1330
1263
  * ── Why the grammar is here ──────────────────────────────────────────────
1331
1264
  *
@@ -1334,11 +1267,23 @@ export function validateKeyedGrantsAgainstTargets(grants, known) {
1334
1267
  * authoritatively because the raw admin API exists. Two enforcement points,
1335
1268
  * one rule, in the dependency-free tree the CLI vendors.
1336
1269
  */
1337
- /** The manifest encoding, versioned with the envelope that carries it. */
1338
- export const FUNCTION_MANIFEST_SCHEMA_VERSION = 1;
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];
1339
1282
  /** Bounds, so a manifest cannot be a way to store an unbounded blob. */
1340
1283
  export const MAX_MANIFEST_QUERIES = 200;
1341
1284
  export const MAX_MANIFEST_NAME_LENGTH = 120;
1285
+ export const MAX_MANIFEST_MODELS = 200;
1286
+ export const MAX_MANIFEST_FAMILIES = 40;
1342
1287
  /**
1343
1288
  * Validate a manifest's grammar and normalize it.
1344
1289
  *
@@ -1365,8 +1310,9 @@ export function parseFunctionManifest(value) {
1365
1310
  if (!document || typeof document !== "object" || Array.isArray(document)) {
1366
1311
  return fail("the manifest must be an object with a `queries` array.");
1367
1312
  }
1368
- if (Number(document.schemaVersion) !== FUNCTION_MANIFEST_SCHEMA_VERSION) {
1369
- return fail(`the manifest declares schemaVersion ${JSON.stringify(document.schemaVersion)}; this platform reads version ${FUNCTION_MANIFEST_SCHEMA_VERSION}.`);
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 ")}.`);
1370
1316
  }
1371
1317
  if (!Array.isArray(document.queries)) {
1372
1318
  return fail("the manifest's `queries` must be an array.");
@@ -1450,89 +1396,51 @@ export function parseFunctionManifest(value) {
1450
1396
  callerScoped: Object.values(params).some((p) => p.caller === true),
1451
1397
  });
1452
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
+ }
1453
1429
  if (errors.length > 0)
1454
1430
  return { ok: false, manifest: null, errors };
1455
1431
  queries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1456
1432
  return {
1457
1433
  ok: true,
1458
- manifest: { schemaVersion: FUNCTION_MANIFEST_SCHEMA_VERSION, queries },
1434
+ manifest: {
1435
+ schemaVersion,
1436
+ queries,
1437
+ models: models ?? [],
1438
+ families: families ?? [],
1439
+ dynamicModels: schemaVersion >= 2 && document.dynamicModels === true,
1440
+ },
1459
1441
  errors: [],
1460
1442
  };
1461
1443
  }
1462
- /**
1463
- * The phase-4 relaxation of the unscoped-read rule, from the intent: "from
1464
- * phase 4 a recorded `$caller` binding on every read satisfies the rule
1465
- * instead".
1466
- *
1467
- * Returns the refusal, or null when the config may be pushed without
1468
- * `unscopedReads = true`.
1469
- *
1470
- * Fail-closed in three ways, each of which is a way an author could otherwise
1471
- * get the flag waived without the property it stands for:
1472
- *
1473
- * - no manifest at all is no evidence, so the flag is still required;
1474
- * - a manifest naming NO query that touches a read-granted model records
1475
- * nothing about how those models are read, so it satisfies nothing;
1476
- * - one non-caller-scoped registration over a read-granted model is enough
1477
- * to require the flag, because that query reads every row for every
1478
- * caller — which is exactly what the flag declares.
1479
- */
1480
- export function callerScopedReadsRefusal(grants, unscopedReads, manifest) {
1481
- if (unscopedReads)
1482
- return null;
1483
- const reads = grants.filter((g) => g.access === "read");
1484
- if (reads.length === 0)
1485
- return null;
1486
- const base = `declares the database read grant${reads.length > 1 ? "s" : ""} ` +
1487
- `${reads.map((g) => `'${g.raw}'`).join(", ")} but not \`unscopedReads = true\`. `;
1488
- if (!manifest) {
1489
- return (base +
1490
- "A function's reads are scoped to its caller only through a `$caller` " +
1491
- "binding on a registered query, and this push recorded no query " +
1492
- "manifest. Add `unscopedReads = true` to the `[function]` table, drop " +
1493
- "the read grant, or register the reads with `defineQuery` and a " +
1494
- "`$caller` parameter.");
1495
- }
1496
- const readModels = new Set(reads.map((g) => g.modelName));
1497
- const touching = manifest.queries.filter((q) => q.models.some((model) => readModels.has(model)));
1498
- if (touching.length === 0) {
1499
- return (base +
1500
- "Its manifest records no registered query over " +
1501
- `${[...readModels].map((m) => `'${m}'`).join(", ")}, so nothing says ` +
1502
- "those reads are scoped to a caller. Add `unscopedReads = true`, drop " +
1503
- "the read grant, or register the reads with a `$caller` parameter.");
1504
- }
1505
- const unscoped = touching.filter((q) => !q.callerScoped);
1506
- if (unscoped.length > 0) {
1507
- return (base +
1508
- `Its registered quer${unscoped.length > 1 ? "ies" : "y"} ` +
1509
- `${unscoped.map((q) => `'${q.name}'`).join(", ")} read ` +
1510
- "a granted model without a `$caller` binding, so that read returns " +
1511
- "every row for every caller. Bind the read to `$caller`, or add " +
1512
- "`unscopedReads = true` — the flag is the human-reviewed artifact that " +
1513
- "says an unscoped read was intended.");
1514
- }
1515
- return null;
1516
- }
1517
- /**
1518
- * Whether a running version's raw (non-query) reads must be refused inside the
1519
- * sandbox — #3187 behavior 23.
1520
- *
1521
- * True exactly when the version was accepted under the relaxation: it holds a
1522
- * database READ grant and did not declare `unscopedReads`. Such a version was
1523
- * pushed on the strength of its manifest saying every read is caller-scoped,
1524
- * so the SDK admits the caller-scoped registered reads and refuses the rest.
1525
- *
1526
- * Honest-code protection at the design doc's stated adequacy: the SDK is
1527
- * platform-owned, but a hostile bundle is not obliged to use it. The gateway
1528
- * and DatabaseDO grant checks (#3182) are unchanged and remain the adversarial
1529
- * boundary.
1530
- */
1531
- export function callerScopedReadsRequired(capabilities, unscopedReads) {
1532
- if (unscopedReads)
1533
- return false;
1534
- return capabilities.some((entry) => /^database:[^:]+:read$/.test(String(entry)));
1535
- }
1536
1444
  // ── src/config-surface/workflow.ts ───────────────────────────────────────
1537
1445
  /**
1538
1446
  * The `workflow` configuration object's definition (issue #2644, phase 1).
@@ -3900,13 +3808,15 @@ export const TRANSFORM_SURFACE = {
3900
3808
  * the header model, and they travel inside the version envelope's TOML bytes
3901
3809
  * rather than as a payload field of their own.
3902
3810
  *
3903
- * `capabilities` and `unscopedReads` joined the accepted set with #3182 (the
3904
- * non-database grant families are still #3183/#3184). Both are version-carried,
3905
- * and both are DERIVED by the server from the TOML inside the pushed envelope
3906
- * rather than sent as payload fields of their own — the file a human reviews on
3907
- * pull has to be the same statement the platform enforces (D3182-002). They are
3908
- * listed in `tomlOnlyKeys` for that reason: they are authored, and they travel
3909
- * as envelope bytes.
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.
3910
3820
  *
3911
3821
  * ── `status` ─────────────────────────────────────────────────────────────
3912
3822
  *
@@ -3933,16 +3843,6 @@ export const SERVER_FUNCTION_SURFACE = {
3933
3843
  writableOn: BOTH,
3934
3844
  validation: "passthrough",
3935
3845
  },
3936
- {
3937
- field: "runAs",
3938
- tomlKey: "runAs",
3939
- type: "string",
3940
- emit: "always",
3941
- writableOn: BOTH,
3942
- // `caller | system` — the identity the function's own API calls run
3943
- // as (the intent, "Who may invoke a function, and as whom?").
3944
- validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController"),
3945
- },
3946
3846
  {
3947
3847
  field: "access",
3948
3848
  tomlKey: "access",
@@ -4005,6 +3905,14 @@ export const SERVER_FUNCTION_SURFACE = {
4005
3905
  "Repointed by a config-version push; versions are nameless, so " +
4006
3906
  "there is no `activeConfigName` for an author to write.",
4007
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
+ },
4008
3916
  deleteLockedAt: {
4009
3917
  kind: "server-owned",
4010
3918
  note: "#3186 — the lease a hard delete takes before it scans for live " +
@@ -4063,20 +3971,14 @@ export const SERVER_FUNCTION_SURFACE = {
4063
3971
  },
4064
3972
  capabilities: {
4065
3973
  kind: "structural",
4066
- note: "The grants this version may use, as " +
4067
- "`database:<databaseType>/<modelName>:read|write` strings (#3182; " +
4068
- "other capability families are #3183/#3184). Stored on the config " +
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 " +
4069
3979
  "version and DERIVED by the server from these authored bytes, so " +
4070
3980
  "the file a human reviews is the authority — a push cannot store a " +
4071
- "grant the TOML does not declare.",
4072
- },
4073
- unscopedReads: {
4074
- kind: "structural",
4075
- note: "Required beside any `database:…:read` grant (#3182). A function's " +
4076
- "reads are not scoped to its caller until `$caller` bindings " +
4077
- "arrive, so the flag is the human-reviewed artifact that says " +
4078
- "reading every row of the model for every caller was intended. " +
4079
- "Version-carried and server-derived, like `capabilities`.",
3981
+ "capability the TOML does not declare.",
4080
3982
  },
4081
3983
  },
4082
3984
  responseOnlyKeys: {},