@reventlessdev/reventless-spec 3.0.0-alpha.134 → 3.0.0-alpha.135

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.
@@ -0,0 +1,281 @@
1
+ /***
2
+ The accounts a deployment is operated as, as a file: `.reventless/users.yaml`.
3
+
4
+ Two platforms read it and they must not disagree about its shape. Locally it *is*
5
+ the identity store — the in-memory auth adapter loads it at startup. On AWS it is
6
+ the record of what was created in a Cognito pool, and `provision-accounts` both
7
+ reads it and writes back into it.
8
+
9
+ Here rather than in either platform package because a manifest that works on one
10
+ platform and silently fails on the other is the whole cost being avoided. It sits
11
+ beside [Identity] and [AdminGroup] for the same reason those do: the vocabulary
12
+ the two platforms share about who is signed in.
13
+
14
+ 🚨 **The password field is a bootstrap credential, not a stored secret.** The file
15
+ is gitignored on every platform, and on AWS the passwords in it are generated per
16
+ deployment rather than committed — see `users.example.yaml` beside each platform.
17
+ */
18
+
19
+ /**
20
+ One account. `groups` is required (`[]` for an unprivileged account).
21
+
22
+ `password` is required but may be empty: an AWS manifest is written before the
23
+ accounts exist, and `provision-accounts` fills the empty ones in. An empty
24
+ password authenticates nowhere, which is the correct reading of "not yet
25
+ provisioned" on both platforms.
26
+
27
+ `userId` is what the platform stamps on rows this account writes. Locally it
28
+ defaults to the username; on AWS it is the `sub` the pool minted, so it can only
29
+ be filled in after the account exists.
30
+ */
31
+ @schema
32
+ type entry = {
33
+ username: string,
34
+ password: string,
35
+ groups: array<string>,
36
+ userId?: string,
37
+ }
38
+
39
+ let entriesSchema = S.array(entrySchema)
40
+
41
+ @module("yaml") external parseYaml: string => JSON.t = "parse"
42
+
43
+ /** Parses a manifest document. Strict: a malformed entry refuses the whole file
44
+ rather than being skipped, because on both platforms a silently dropped account
45
+ reads as a login that stopped working for no stated reason. */
46
+ let parseString = (yamlText: string): result<array<entry>, string> =>
47
+ try {
48
+ Ok(S.parseOrThrow(parseYaml(yamlText), ~to=entriesSchema))
49
+ } catch {
50
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr("YAML parse error"))
51
+ | _ => Error("YAML parse error")
52
+ }
53
+
54
+ let parseFile = (path: string): result<array<entry>, string> =>
55
+ try {
56
+ parseString(NodeFs.readFileSync(path))
57
+ } catch {
58
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot read ${path}`))
59
+ | _ => Error(`Cannot read ${path}`)
60
+ }
61
+
62
+ /** Where both platforms keep it: beside the package the process runs from. */
63
+ let defaultPath = (): string => NodePath.join([NodeProcess.cwd(), ".reventless", "users.yaml"])
64
+
65
+ // ── Writing back ─────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ A value provisioning learned and the file does not know yet, addressed by the
69
+ entry's position in the document.
70
+
71
+ `password` is written **only into an empty field** — see [applyFills]. `userId` is
72
+ written whenever it differs, because on AWS the pool mints it and the file is only
73
+ ever a copy: a stale one is not a documentation slip but rows keyed to an id
74
+ nobody holds.
75
+ */
76
+ type fill = {
77
+ index: int,
78
+ password: option<string>,
79
+ userId: option<string>,
80
+ }
81
+
82
+ /** What a fill actually did, so the caller can report it rather than guess. */
83
+ type filled = {
84
+ index: int,
85
+ passwordWritten: bool,
86
+ userIdWritten: bool,
87
+ }
88
+
89
+ type document
90
+
91
+ @module("yaml") external parseDocument: string => document = "parseDocument"
92
+ @send external getIn: (document, array<JSON.t>) => JSON.t = "getIn"
93
+ @send external setIn: (document, array<JSON.t>, JSON.t) => unit = "setIn"
94
+ @send external documentToString: document => string = "toString"
95
+
96
+ let path = (index: int, field: string): array<JSON.t> => [
97
+ JSON.Number(index->Int.toFloat),
98
+ JSON.String(field),
99
+ ]
100
+
101
+ let currentString = (doc: document, index: int, field: string): option<string> =>
102
+ switch doc->getIn(path(index, field)) {
103
+ | JSON.String(s) => Some(s)
104
+ | _ => None
105
+ }
106
+
107
+ /**
108
+ Applies fills to a manifest document, returning the document's new text.
109
+
110
+ 🚨 **Round-trips through `parseDocument` rather than re-serializing the parsed
111
+ entries**, so every comment survives. Both manifests carry more explanation than
112
+ data — which account is elevated and why, which pool these subs came from — and a
113
+ tool that silently deleted it would cost more than it saved.
114
+
115
+ 🚨 **A password already in the file is never replaced.** A second run is the
116
+ normal case (an operator adding one account to four), and the failure mode of the
117
+ obvious implementation is overwriting a credential somebody is signed in with.
118
+ Empty means empty or whitespace; anything else is somebody's choice.
119
+ */
120
+ let applyFills = (yamlText: string, fills: array<fill>): result<(string, array<filled>), string> =>
121
+ try {
122
+ let doc = parseDocument(yamlText)
123
+ let report = fills->Array.map(({index, password, userId}) => {
124
+ let passwordWritten = switch password {
125
+ | Some(generated)
126
+ if doc->currentString(index, "password")->Option.getOr("")->String.trim == "" =>
127
+ doc->setIn(path(index, "password"), JSON.String(generated))
128
+ true
129
+ | _ => false
130
+ }
131
+ let userIdWritten = switch userId {
132
+ | Some(minted) if doc->currentString(index, "userId") != Some(minted) =>
133
+ doc->setIn(path(index, "userId"), JSON.String(minted))
134
+ true
135
+ | _ => false
136
+ }
137
+ {index, passwordWritten, userIdWritten}
138
+ })
139
+ Ok((doc->documentToString, report))
140
+ } catch {
141
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr("Cannot rewrite the manifest"))
142
+ | _ => Error("Cannot rewrite the manifest")
143
+ }
144
+
145
+ /** [applyFills] against a file on disk. Reads, rewrites, writes — and writes
146
+ nothing at all when no fill applied, so a fully-provisioned manifest keeps its
147
+ mtime and a second run is visibly a no-op. */
148
+ let fillFile = (~path as file: string, ~fills: array<fill>): result<array<filled>, string> =>
149
+ switch try Ok(NodeFs.readFileSync(file)) catch {
150
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot read ${file}`))
151
+ | _ => Error(`Cannot read ${file}`)
152
+ } {
153
+ | Error(_) as e => e
154
+ | Ok(text) =>
155
+ switch applyFills(text, fills) {
156
+ | Error(_) as e => e
157
+ | Ok((_, report)) if !(report->Array.some(r => r.passwordWritten || r.userIdWritten)) =>
158
+ Ok(report)
159
+ | Ok((rewritten, report)) =>
160
+ try {
161
+ NodeFs.writeFileSync(file, rewritten)
162
+ Ok(report)
163
+ } catch {
164
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot write ${file}`))
165
+ | _ => Error(`Cannot write ${file}`)
166
+ }
167
+ }
168
+ }
169
+
170
+ // ── Finding it ───────────────────────────────────────────────────────────────
171
+
172
+ /** The template each platform package keeps beside itself, committed, as the
173
+ declaration of its cast. Both examples spell it this way and so does the local
174
+ setup script, so it is a convention rather than a setting. */
175
+ let templateName = "users.example.yaml"
176
+
177
+ type located =
178
+ | Declared(string)
179
+ | SeededFrom(string, string)
180
+
181
+ let pathOf = (located: located): string =>
182
+ switch located {
183
+ | Declared(file) | SeededFrom(file, _) => file
184
+ }
185
+
186
+ /**
187
+ The manifest to work from, copying the template into place when there is none.
188
+
189
+ `.reventless/` is gitignored on every platform, so a fresh clone has no manifest
190
+ and the first thing anyone met was `cp users.example.yaml .reventless/users.yaml`
191
+ — ceremony of the same kind as passing a pool id the deployment already knows.
192
+ `scripts/setup.mjs` has done this for the local platform all along; this generalises
193
+ it to any platform package, which is why it lives beside the format rather than
194
+ inside either platform's tooling.
195
+
196
+ 🚨 **A named path is never seeded.** Passing one is a claim that it exists, and
197
+ copying a template over that claim would answer a typo by provisioning the wrong
198
+ cast. Only the default path is filled in.
199
+ */
200
+ let locate = (~given: option<string>=?, ()): result<located, string> =>
201
+ switch given {
202
+ | Some(file) =>
203
+ NodeFs.existsSync(file)
204
+ ? Ok(Declared(file))
205
+ : Error(`${file} does not exist — a named manifest is read, never created`)
206
+ | None =>
207
+ let file = defaultPath()
208
+ if NodeFs.existsSync(file) {
209
+ Ok(Declared(file))
210
+ } else {
211
+ let template = NodePath.join([NodeProcess.cwd(), templateName])
212
+ if !NodeFs.existsSync(template) {
213
+ Error(
214
+ `no ${file} and no ${templateName} here to start one from. Declare the accounts in .reventless/users.yaml — each entry a username, a password (empty to have one generated) and the groups it belongs to`,
215
+ )
216
+ } else {
217
+ try {
218
+ NodeFs.mkdirSync(NodePath.dirname(file), {recursive: true})
219
+ NodeFs.cpSync(template, file, {})
220
+ Ok(SeededFrom(file, template))
221
+ } catch {
222
+ | JsExn(err) =>
223
+ Error(
224
+ `could not copy ${templateName} into place: ${JsExn.message(err)->Option.getOr(
225
+ "unknown error",
226
+ )}`,
227
+ )
228
+ | _ => Error(`could not copy ${templateName} into place`)
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ // ── Preparing ────────────────────────────────────────────────────────────────
235
+
236
+ /** One account after preparation: what the file now declares, and whether this
237
+ run is what put the password there. */
238
+ type prepared = {
239
+ entry: entry,
240
+ passwordGenerated: bool,
241
+ }
242
+
243
+ /**
244
+ Validates a manifest and fills in what it leaves blank.
245
+
246
+ 🚨 **This is the whole of provisioning on the local platform, and the first half
247
+ of it on AWS.** Locally the manifest *is* the user store — the auth adapter
248
+ hydrates from it at startup — so once every account has a password there is
249
+ nothing left to create. On AWS the accounts still have to be made in a pool, and
250
+ that half is Cognito's, in `reventless/aws`.
251
+
252
+ Which is why it lives here and takes no client, no region and no credentials: a
253
+ developer with an empty password field can run it on a laptop that has never held
254
+ an AWS key.
255
+
256
+ The passwords it mints are written straight back, because a generated credential
257
+ that is only printed is one the seed client cannot read.
258
+ */
259
+ let prepare = (~path as file: string): result<array<prepared>, string> =>
260
+ switch parseFile(file) {
261
+ | Error(_) as e => e
262
+ | Ok(entries) =>
263
+ let minted =
264
+ entries->Array.map(entry =>
265
+ entry.password->String.trim == "" ? Some(Util_Password.generate()) : None
266
+ )
267
+ let fills = minted->Array.mapWithIndex((password, index) => {index, password, userId: None})
268
+ switch fillFile(~path=file, ~fills) {
269
+ | Error(_) as e => e
270
+ | Ok(report) =>
271
+ Ok(
272
+ entries->Array.mapWithIndex((entry, index) => {
273
+ let written = report->Array.get(index)->Option.mapOr(false, r => r.passwordWritten)
274
+ switch (written, minted->Array.getUnsafe(index)) {
275
+ | (true, Some(password)) => {entry: {...entry, password}, passwordGenerated: true}
276
+ | _ => {entry, passwordGenerated: false}
277
+ }
278
+ }),
279
+ )
280
+ }
281
+ }
@@ -0,0 +1,311 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as Sury from "sury";
5
+ import * as Yaml from "yaml";
6
+ import * as Nodefs from "node:fs";
7
+ import * as Nodepath from "node:path";
8
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
9
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
10
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
11
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
12
+ import * as Util_Password$Reventless from "../util/Util_Password.res.mjs";
13
+
14
+ let entrySchema = Sury.$schema(s => ({
15
+ username: s.m(Sury.string),
16
+ password: s.m(Sury.string),
17
+ groups: s.m(Sury.array(Sury.string)),
18
+ userId: s.m(Sury.$option(Sury.string))
19
+ }));
20
+
21
+ let entriesSchema = Sury.array(entrySchema);
22
+
23
+ function parseString(yamlText) {
24
+ try {
25
+ return {
26
+ TAG: "Ok",
27
+ _0: S.parseOrThrow(Yaml.parse(yamlText), entriesSchema)
28
+ };
29
+ } catch (raw_err) {
30
+ let err = Primitive_exceptions.internalToException(raw_err);
31
+ if (err.RE_EXN_ID === "JsExn") {
32
+ return {
33
+ TAG: "Error",
34
+ _0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "YAML parse error")
35
+ };
36
+ } else {
37
+ return {
38
+ TAG: "Error",
39
+ _0: "YAML parse error"
40
+ };
41
+ }
42
+ }
43
+ }
44
+
45
+ function parseFile(path) {
46
+ try {
47
+ return parseString(Nodefs.readFileSync(path, "utf8"));
48
+ } catch (raw_err) {
49
+ let err = Primitive_exceptions.internalToException(raw_err);
50
+ if (err.RE_EXN_ID === "JsExn") {
51
+ return {
52
+ TAG: "Error",
53
+ _0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), `Cannot read ` + path)
54
+ };
55
+ } else {
56
+ return {
57
+ TAG: "Error",
58
+ _0: `Cannot read ` + path
59
+ };
60
+ }
61
+ }
62
+ }
63
+
64
+ function defaultPath() {
65
+ return Nodepath.join(process.cwd(), ".reventless", "users.yaml");
66
+ }
67
+
68
+ function path(index, field) {
69
+ return [
70
+ index,
71
+ field
72
+ ];
73
+ }
74
+
75
+ function currentString(doc, index, field) {
76
+ let s = doc.getIn(path(index, field));
77
+ if (typeof s === "string") {
78
+ return s;
79
+ }
80
+ }
81
+
82
+ function applyFills(yamlText, fills) {
83
+ try {
84
+ let doc = Yaml.parseDocument(yamlText);
85
+ let report = fills.map(param => {
86
+ let userId = param.userId;
87
+ let password = param.password;
88
+ let index = param.index;
89
+ let passwordWritten = password !== undefined && Stdlib_Option.getOr(currentString(doc, index, "password"), "").trim() === "" ? (doc.setIn(path(index, "password"), password), true) : false;
90
+ let userIdWritten = userId !== undefined && Primitive_object.notequal(currentString(doc, index, "userId"), userId) ? (doc.setIn(path(index, "userId"), userId), true) : false;
91
+ return {
92
+ index: index,
93
+ passwordWritten: passwordWritten,
94
+ userIdWritten: userIdWritten
95
+ };
96
+ });
97
+ return {
98
+ TAG: "Ok",
99
+ _0: [
100
+ doc.toString(),
101
+ report
102
+ ]
103
+ };
104
+ } catch (raw_err) {
105
+ let err = Primitive_exceptions.internalToException(raw_err);
106
+ if (err.RE_EXN_ID === "JsExn") {
107
+ return {
108
+ TAG: "Error",
109
+ _0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "Cannot rewrite the manifest")
110
+ };
111
+ } else {
112
+ return {
113
+ TAG: "Error",
114
+ _0: "Cannot rewrite the manifest"
115
+ };
116
+ }
117
+ }
118
+ }
119
+
120
+ function fillFile(file, fills) {
121
+ let e;
122
+ try {
123
+ e = {
124
+ TAG: "Ok",
125
+ _0: Nodefs.readFileSync(file, "utf8")
126
+ };
127
+ } catch (raw_err) {
128
+ let err = Primitive_exceptions.internalToException(raw_err);
129
+ e = err.RE_EXN_ID === "JsExn" ? ({
130
+ TAG: "Error",
131
+ _0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), `Cannot read ` + file)
132
+ }) : ({
133
+ TAG: "Error",
134
+ _0: `Cannot read ` + file
135
+ });
136
+ }
137
+ if (e.TAG !== "Ok") {
138
+ return e;
139
+ }
140
+ let e$1 = applyFills(e._0, fills);
141
+ if (e$1.TAG !== "Ok") {
142
+ return e$1;
143
+ }
144
+ let match = e$1._0;
145
+ let report = match[1];
146
+ if (!report.some(r => {
147
+ if (r.passwordWritten) {
148
+ return true;
149
+ } else {
150
+ return r.userIdWritten;
151
+ }
152
+ })) {
153
+ return {
154
+ TAG: "Ok",
155
+ _0: report
156
+ };
157
+ }
158
+ try {
159
+ Nodefs.writeFileSync(file, match[0], "utf8");
160
+ return {
161
+ TAG: "Ok",
162
+ _0: report
163
+ };
164
+ } catch (raw_err$1) {
165
+ let err$1 = Primitive_exceptions.internalToException(raw_err$1);
166
+ if (err$1.RE_EXN_ID === "JsExn") {
167
+ return {
168
+ TAG: "Error",
169
+ _0: Stdlib_Option.getOr(Stdlib_JsExn.message(err$1._1), `Cannot write ` + file)
170
+ };
171
+ } else {
172
+ return {
173
+ TAG: "Error",
174
+ _0: `Cannot write ` + file
175
+ };
176
+ }
177
+ }
178
+ }
179
+
180
+ let templateName = "users.example.yaml";
181
+
182
+ function pathOf(located) {
183
+ return located._0;
184
+ }
185
+
186
+ function locate(given, param) {
187
+ if (given !== undefined) {
188
+ if (Nodefs.existsSync(given)) {
189
+ return {
190
+ TAG: "Ok",
191
+ _0: {
192
+ TAG: "Declared",
193
+ _0: given
194
+ }
195
+ };
196
+ } else {
197
+ return {
198
+ TAG: "Error",
199
+ _0: given + ` does not exist — a named manifest is read, never created`
200
+ };
201
+ }
202
+ }
203
+ let file = defaultPath();
204
+ if (Nodefs.existsSync(file)) {
205
+ return {
206
+ TAG: "Ok",
207
+ _0: {
208
+ TAG: "Declared",
209
+ _0: file
210
+ }
211
+ };
212
+ }
213
+ let template = Nodepath.join(process.cwd(), templateName);
214
+ if (!Nodefs.existsSync(template)) {
215
+ return {
216
+ TAG: "Error",
217
+ _0: `no ` + file + ` and no ` + templateName + ` here to start one from. Declare the accounts in .reventless/users.yaml — each entry a username, a password (empty to have one generated) and the groups it belongs to`
218
+ };
219
+ }
220
+ try {
221
+ Nodefs.mkdirSync(Nodepath.dirname(file), {
222
+ recursive: true
223
+ });
224
+ Nodefs.cpSync(template, file, {});
225
+ return {
226
+ TAG: "Ok",
227
+ _0: {
228
+ TAG: "SeededFrom",
229
+ _0: file,
230
+ _1: template
231
+ }
232
+ };
233
+ } catch (raw_err) {
234
+ let err = Primitive_exceptions.internalToException(raw_err);
235
+ if (err.RE_EXN_ID === "JsExn") {
236
+ return {
237
+ TAG: "Error",
238
+ _0: `could not copy ` + templateName + ` into place: ` + Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "unknown error")
239
+ };
240
+ } else {
241
+ return {
242
+ TAG: "Error",
243
+ _0: `could not copy ` + templateName + ` into place`
244
+ };
245
+ }
246
+ }
247
+ }
248
+
249
+ function prepare(file) {
250
+ let e = parseFile(file);
251
+ if (e.TAG !== "Ok") {
252
+ return e;
253
+ }
254
+ let entries = e._0;
255
+ let minted = entries.map(entry => {
256
+ if (entry.password.trim() === "") {
257
+ return Util_Password$Reventless.generate();
258
+ }
259
+ });
260
+ let fills = minted.map((password, index) => ({
261
+ index: index,
262
+ password: password,
263
+ userId: undefined
264
+ }));
265
+ let e$1 = fillFile(file, fills);
266
+ if (e$1.TAG !== "Ok") {
267
+ return e$1;
268
+ }
269
+ let report = e$1._0;
270
+ return {
271
+ TAG: "Ok",
272
+ _0: entries.map((entry, index) => {
273
+ let written = Stdlib_Option.mapOr(report[index], false, r => r.passwordWritten);
274
+ let match = minted[index];
275
+ if (!written) {
276
+ return {
277
+ entry: entry,
278
+ passwordGenerated: false
279
+ };
280
+ }
281
+ if (match === undefined) {
282
+ return {
283
+ entry: entry,
284
+ passwordGenerated: false
285
+ };
286
+ }
287
+ let newrecord = {...entry};
288
+ return {
289
+ entry: (newrecord.password = match, newrecord),
290
+ passwordGenerated: true
291
+ };
292
+ })
293
+ };
294
+ }
295
+
296
+ export {
297
+ entrySchema,
298
+ entriesSchema,
299
+ parseString,
300
+ parseFile,
301
+ defaultPath,
302
+ path,
303
+ currentString,
304
+ applyFills,
305
+ fillFile,
306
+ templateName,
307
+ pathOf,
308
+ locate,
309
+ prepare,
310
+ }
311
+ /* entrySchema Not a pure module */
@@ -0,0 +1,27 @@
1
+ /**
2
+ The group whose members administer a deployment.
3
+
4
+ One definition, because the name is decided **twice and independently**: an
5
+ authorization rule names it to allow a read, and [OwnerScope.elevatedGroups]
6
+ names it to exempt a caller from `@owner` narrowing. Nothing checks that the two
7
+ agree, and a deployment where they disagree is refused nowhere — the
8
+ administrator passes every authorization check and then reads *empty views*,
9
+ because every owner-scoped view filters them out. Empty-because-scoped and
10
+ empty-because-there-is-nothing look identical from outside.
11
+
12
+ In `spec` rather than in a provider package because **both platforms hard-code
13
+ it**: the AWS platform decorates its admin API with it, while the local platform
14
+ both wraps its `Platform_*` fields in it and hands it to the built-in developer
15
+ account. A constant only one of them could reach would leave the other exactly as
16
+ divergent as it is today.
17
+
18
+ 🚨 **This buys agreement, not the freedom to rename.** `"Admin"` is written into
19
+ every example's `@@reventless.authorize` annotations, and a PPX annotation takes a
20
+ literal — so the consumers that matter most *cannot* read this constant. Changing
21
+ the value here would leave those annotations behind and silently split the two
22
+ mechanisms this exists to hold together, which is the failure above rather than a
23
+ compile error. Renaming the administrator group is a different and much larger
24
+ act. `AdminGroupTest` asserts that the constant and the platform's own annotation
25
+ still say the same thing, because that boundary can carry no other check.
26
+ */
27
+ let name = "Admin"
@@ -0,0 +1,9 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ let name = "Admin";
5
+
6
+ export {
7
+ name,
8
+ }
9
+ /* No side effect */
@@ -94,6 +94,33 @@ let setElevatedGroups = (groups: array<string>) => explicitElevatedGroups := Som
94
94
  /** Forget an explicit setting and fall back to the environment again. */
95
95
  let clearElevatedGroups = () => explicitElevatedGroups := None
96
96
 
97
+ /**
98
+ Name the elevated groups only where the deployment has not already answered.
99
+
100
+ For a platform that wants a working default without overriding an operator who
101
+ stated one — a stack that declares an administrator group has every reason to
102
+ exempt it, and no business overruling a deployment that named different operators.
103
+
104
+ "Already answered" includes the **environment**, not only an explicit call. A
105
+ deployment that set `REVENTLESS_ELEVATED_GROUPS` in CI has named its operators,
106
+ and a default that beat it would leave the runtime acting on a value no source in
107
+ that deployment states. This is the same contract, one level up, that
108
+ `Util_OwnerScopeEnv.applyElevatedGroupsDefault` keeps with a Lambda's own
109
+ environment: the more specific statement wins, and the default only fills silence.
110
+
111
+ 🚨 **An explicit empty list is an answer, and this must not fill it in.** A
112
+ deployment that deliberately elevates nobody has decided that; a default landing
113
+ on top would silently re-grant the cross-owner read it withheld. That is why this
114
+ reads [explicitElevatedGroups] rather than [elevatedGroups] — the latter answers
115
+ `[]` for "nobody" and for "nothing said" alike, and those must not be treated the
116
+ same here.
117
+ */
118
+ let defaultElevatedGroups = (groups: array<string>) =>
119
+ switch (explicitElevatedGroups.contents, _elevatedGroupsEnv) {
120
+ | (None, None) => explicitElevatedGroups := Some(groups)
121
+ | _ => ()
122
+ }
123
+
97
124
  let parseElevatedGroups = (raw: string): array<string> =>
98
125
  raw
99
126
  ->String.split(",")
@@ -21,6 +21,17 @@ function clearElevatedGroups() {
21
21
  explicitElevatedGroups.contents = undefined;
22
22
  }
23
23
 
24
+ function defaultElevatedGroups(groups) {
25
+ let match = explicitElevatedGroups.contents;
26
+ let match$1 = process.env.REVENTLESS_ELEVATED_GROUPS;
27
+ if (match !== undefined || match$1 !== undefined) {
28
+ return;
29
+ } else {
30
+ explicitElevatedGroups.contents = groups;
31
+ return;
32
+ }
33
+ }
34
+
24
35
  function parseElevatedGroups(raw) {
25
36
  return raw.split(",").map(prim => prim.trim()).filter(part => part.length > 0);
26
37
  }
@@ -226,6 +237,7 @@ export {
226
237
  explicitElevatedGroups,
227
238
  setElevatedGroups,
228
239
  clearElevatedGroups,
240
+ defaultElevatedGroups,
229
241
  parseElevatedGroups,
230
242
  elevatedGroups,
231
243
  classify,