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.
- package/dist/bin/primitive.js +110 -21
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +1 -1
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/config.js +8 -4
- package/dist/src/commands/config.js.map +1 -1
- package/dist/src/commands/database-type-configs.js +1 -1
- package/dist/src/commands/database-type-configs.js.map +1 -1
- package/dist/src/commands/databases.js +25 -31
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +9 -24
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/env.d.ts +1 -1
- package/dist/src/commands/env.js +14 -11
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/functions.js +173 -4
- package/dist/src/commands/functions.js.map +1 -1
- package/dist/src/commands/groups.js +1 -1
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/init.d.ts +12 -15
- package/dist/src/commands/init.js +36 -77
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/prompts.js +54 -39
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.js +1 -1
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.js +17 -4
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/sync-app-settings.js +2 -4
- package/dist/src/commands/sync-app-settings.js.map +1 -1
- package/dist/src/commands/sync.d.ts +41 -2
- package/dist/src/commands/sync.js +635 -364
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +62 -31
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +23 -2
- package/dist/src/lib/api-client.js +24 -5
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-match-guard.d.ts +44 -0
- package/dist/src/lib/app-match-guard.js +72 -0
- package/dist/src/lib/app-match-guard.js.map +1 -0
- package/dist/src/lib/block-selector.d.ts +58 -0
- package/dist/src/lib/block-selector.js +92 -0
- package/dist/src/lib/block-selector.js.map +1 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +11 -8
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +108 -54
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.js +1 -3
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/config.d.ts +2 -2
- package/dist/src/lib/config.js +2 -2
- package/dist/src/lib/credentials-store.d.ts +3 -3
- package/dist/src/lib/credentials-store.js +3 -3
- package/dist/src/lib/credentials-store.js.map +1 -1
- package/dist/src/lib/document-export-permissions.d.ts +30 -0
- package/dist/src/lib/document-export-permissions.js +54 -0
- package/dist/src/lib/document-export-permissions.js.map +1 -0
- package/dist/src/lib/env-resolver-core.d.ts +41 -9
- package/dist/src/lib/env-resolver-core.js +84 -13
- package/dist/src/lib/env-resolver-core.js.map +1 -1
- package/dist/src/lib/env-resolver.d.ts +3 -3
- package/dist/src/lib/env-resolver.js +2 -2
- package/dist/src/lib/function-bundle.d.ts +11 -1
- package/dist/src/lib/function-bundle.js +13 -1
- package/dist/src/lib/function-bundle.js.map +1 -1
- package/dist/src/lib/function-collect.d.ts +123 -0
- package/dist/src/lib/function-collect.js +463 -0
- package/dist/src/lib/function-collect.js.map +1 -0
- package/dist/src/lib/function-db-types.d.ts +149 -0
- package/dist/src/lib/function-db-types.js +587 -0
- package/dist/src/lib/function-db-types.js.map +1 -0
- package/dist/src/lib/function-grants-preflight.d.ts +64 -0
- package/dist/src/lib/function-grants-preflight.js +100 -0
- package/dist/src/lib/function-grants-preflight.js.map +1 -0
- package/dist/src/lib/function-sync.d.ts +23 -0
- package/dist/src/lib/function-sync.js +65 -1
- package/dist/src/lib/function-sync.js.map +1 -1
- package/dist/src/lib/function-triggers.d.ts +6 -0
- package/dist/src/lib/function-triggers.js +63 -4
- package/dist/src/lib/function-triggers.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +340 -0
- package/dist/src/lib/generated-config-surfaces.js +669 -17
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/generated-sdk-types.d.ts +12 -0
- package/dist/src/lib/generated-sdk-types.js +13 -0
- package/dist/src/lib/generated-sdk-types.js.map +1 -0
- package/dist/src/lib/init-adopt.js +2 -2
- package/dist/src/lib/init-adopt.js.map +1 -1
- package/dist/src/lib/init-assets.js +3 -3
- package/dist/src/lib/init-config.d.ts +1 -1
- package/dist/src/lib/init-ios-links.js +1 -1
- package/dist/src/lib/init-ios-links.js.map +1 -1
- package/dist/src/lib/init-plan.d.ts +1 -1
- package/dist/src/lib/init-plan.js +1 -1
- package/dist/src/lib/init-xcode.d.ts +2 -2
- package/dist/src/lib/init-xcode.js +6 -5
- package/dist/src/lib/init-xcode.js.map +1 -1
- package/dist/src/lib/local-state.d.ts +1 -1
- package/dist/src/lib/local-state.js +1 -1
- package/dist/src/lib/local-test-cases.d.ts +1 -1
- package/dist/src/lib/local-test-cases.js +2 -1
- package/dist/src/lib/local-test-cases.js.map +1 -1
- package/dist/src/lib/migration-nag.d.ts +2 -2
- package/dist/src/lib/migration-nag.js +4 -3
- package/dist/src/lib/migration-nag.js.map +1 -1
- package/dist/src/lib/project-config.d.ts +23 -20
- package/dist/src/lib/project-config.js +27 -45
- package/dist/src/lib/project-config.js.map +1 -1
- package/dist/src/lib/snapshots.d.ts +2 -2
- package/dist/src/lib/snapshots.js +2 -2
- package/dist/src/lib/swift-codegen/generator.d.ts +1 -1
- package/dist/src/lib/swift-codegen/generator.js +1 -1
- package/dist/src/lib/sync-dir-selector.d.ts +1 -1
- package/dist/src/lib/sync-dir-selector.js +1 -1
- package/dist/src/lib/sync-paths.d.ts +52 -16
- package/dist/src/lib/sync-paths.js +68 -22
- package/dist/src/lib/sync-paths.js.map +1 -1
- package/dist/src/lib/sync-resource-types.d.ts +2 -2
- package/dist/src/lib/sync-resource-types.js +2 -2
- package/dist/src/lib/template.d.ts +2 -2
- package/dist/src/lib/template.js +2 -2
- package/dist/src/lib/test-case-keys.d.ts +1 -1
- package/dist/src/lib/test-case-keys.js +1 -1
- package/dist/src/lib/toml-database-config.js +19 -2
- package/dist/src/lib/toml-database-config.js.map +1 -1
- package/dist/src/lib/workflow-codegen/generator.d.ts +1 -1
- package/dist/src/lib/workflow-codegen/generator.js +1 -1
- 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
|
|
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 `.
|
|
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",
|