@reventlessdev/reventless-spec 3.0.0-alpha.154 → 3.0.0-alpha.155
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/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/src/components/Owner.res +30 -0
- package/src/components/Owner.res.mjs +36 -0
- package/src/lifecycle/CheckLifecycleModel.res +3 -0
- package/src/lifecycle/CheckLifecycleModel.res.mjs +3 -0
- package/src/types/DeploymentProvenance.res +84 -0
- package/src/types/DeploymentProvenance.res.mjs +86 -0
- package/src/types/Environments.res +230 -0
- package/src/types/Environments.res.mjs +314 -0
- package/src/types/Message.res +21 -0
- package/src/types/Message.res.mjs +16 -1
- package/src/types/OwnerScope.res +72 -0
- package/src/types/OwnerScope.res.mjs +73 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.155 (2026-10-01)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **spec, aws, core, local:** an app declares its environments, and every deployment records its commit ([c7cc925](https://github.com/ReventlessDev/reventless-core/commit/c7cc92573e97742b681a9000cea0bdfd6e9a33fc))
|
|
11
|
+
* **spec, core, gwt, ppx:** a command acts only on what its caller owns ([2636f0e](https://github.com/ReventlessDev/reventless-core/commit/2636f0ee44797ee2c0b0c40a7107cf306113173f))
|
|
12
|
+
|
|
13
|
+
|
|
6
14
|
# 3.0.0-alpha.154 (2026-09-29)
|
|
7
15
|
|
|
8
16
|
**Note:** Version bump only for package @reventlessdev/reventless-spec
|
package/package.json
CHANGED
package/src/components/Owner.res
CHANGED
|
@@ -138,3 +138,33 @@ let variantFieldNames = (schema: S.t<unknown>, ~variant: string): array<string>
|
|
|
138
138
|
| _ => []
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
The owner fields of every constructor that declares one, keyed by its tag.
|
|
144
|
+
|
|
145
|
+
Read off a slice's consumed events, this says which recorded facts name the
|
|
146
|
+
owner of the partition a command acts on. An empty dict means the union records
|
|
147
|
+
no owner, and the handler then has nothing to enforce — so, as with every reader
|
|
148
|
+
here, it must be handed the real schema: a permissive stand-in answers empty.
|
|
149
|
+
*/
|
|
150
|
+
let fieldNamesByVariant = (schema: S.t<unknown>): dict<array<string>> => {
|
|
151
|
+
let byVariant = Dict.make()
|
|
152
|
+
let visit = (properties: dict<S.t<unknown>>) =>
|
|
153
|
+
switch (properties->Dict.get("TAG"), fieldNamesOfProperties(properties)) {
|
|
154
|
+
| (_, []) => ()
|
|
155
|
+
| (Some(String({const: ?Some(name)})), fields) => byVariant->Dict.set(name, fields)
|
|
156
|
+
| _ => ()
|
|
157
|
+
}
|
|
158
|
+
switch schema {
|
|
159
|
+
| AnyOf({anyOf}) =>
|
|
160
|
+
anyOf->Array.forEach(v =>
|
|
161
|
+
switch v {
|
|
162
|
+
| Object({properties}) => visit(properties)
|
|
163
|
+
| _ => ()
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
| Object({properties}) => visit(properties)
|
|
167
|
+
| _ => ()
|
|
168
|
+
}
|
|
169
|
+
byVariant
|
|
170
|
+
}
|
|
@@ -90,6 +90,41 @@ function variantFieldNames(schema, variant) {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
function fieldNamesByVariant(schema) {
|
|
94
|
+
let byVariant = {};
|
|
95
|
+
let visit = properties => {
|
|
96
|
+
let match = properties["TAG"];
|
|
97
|
+
let match$1 = fieldNamesOfProperties(properties);
|
|
98
|
+
if (match$1.length === 0) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (match === undefined) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (match.type !== "string") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
let name = match.const;
|
|
108
|
+
if (name !== undefined) {
|
|
109
|
+
byVariant[name] = match$1;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
switch (schema.type) {
|
|
114
|
+
case "object" :
|
|
115
|
+
visit(schema.properties);
|
|
116
|
+
break;
|
|
117
|
+
case "anyOf" :
|
|
118
|
+
schema.anyOf.forEach(v => {
|
|
119
|
+
if (v.type === "object") {
|
|
120
|
+
return visit(v.properties);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
return byVariant;
|
|
126
|
+
}
|
|
127
|
+
|
|
93
128
|
export {
|
|
94
129
|
ownerId,
|
|
95
130
|
mark,
|
|
@@ -100,5 +135,6 @@ export {
|
|
|
100
135
|
fieldNamesOfProperties,
|
|
101
136
|
fieldNames,
|
|
102
137
|
variantFieldNames,
|
|
138
|
+
fieldNamesByVariant,
|
|
103
139
|
}
|
|
104
140
|
/* ownerId Not a pure module */
|
|
@@ -687,6 +687,9 @@ let observe = (
|
|
|
687
687
|
|
|
688
688
|
scenarios->Array.filterMap(s =>
|
|
689
689
|
switch s.whenElements->Array.get(0) {
|
|
690
|
+
// `thenRefused`: the caller did not own the row, which says nothing about
|
|
691
|
+
// the states the command is legal in.
|
|
692
|
+
| Some(_) if s.thenKind == "forbidden" => None
|
|
690
693
|
| Some(command) if s.whenKind == "command" =>
|
|
691
694
|
let idField = idFieldFor(command.name)
|
|
692
695
|
let idValue = idField->Option.flatMap(field => command.values->valueOf(~field))
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/***
|
|
2
|
+
Where a deployment came from: the commit it was built from, whether the tree had
|
|
3
|
+
uncommitted changes, and the environment it was made for.
|
|
4
|
+
|
|
5
|
+
Read by the deploy while it runs, and recorded with the deployment, so a reader
|
|
6
|
+
can say which commit runs in which environment without asking whoever deployed.
|
|
7
|
+
A deployment from CI knows its commit from the CI's variables; one from a laptop
|
|
8
|
+
asks git. Neither gives up a timestamp in place of a commit: a timestamp is not
|
|
9
|
+
an answer to *which commit*, and a reader cannot tell it from one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type t = {
|
|
13
|
+
commit: option<string>,
|
|
14
|
+
/** True only when the commit came from git and the working tree had changes:
|
|
15
|
+
what was deployed is not what the commit says. */
|
|
16
|
+
dirty: bool,
|
|
17
|
+
environment: option<string>,
|
|
18
|
+
purpose: option<string>,
|
|
19
|
+
tag: option<string>,
|
|
20
|
+
pullRequest: option<int>,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What git can say about the checkout the deploy runs in. */
|
|
24
|
+
type git = {
|
|
25
|
+
head: unit => option<string>,
|
|
26
|
+
dirty: unit => bool,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let nonEmpty = (value: option<string>): option<string> =>
|
|
30
|
+
value->Option.flatMap(v => v->String.trim == "" ? None : Some(v->String.trim))
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
The provenance of a deployment from the process's variables, asking git only when
|
|
34
|
+
no CI variable names the commit.
|
|
35
|
+
|
|
36
|
+
`REVENTLESS_COMMIT` comes first because on a pull request `GITHUB_SHA` is the merge
|
|
37
|
+
commit GitHub made, not the head the review environment runs.
|
|
38
|
+
*/
|
|
39
|
+
let fromEnv = (env: dict<string>, ~git: git): t => {
|
|
40
|
+
let get = name => env->Dict.get(name)->nonEmpty
|
|
41
|
+
let fromCi =
|
|
42
|
+
get("REVENTLESS_COMMIT")
|
|
43
|
+
->Option.orElse(get("GITHUB_SHA"))
|
|
44
|
+
->Option.orElse(get("CI_COMMIT_SHA"))
|
|
45
|
+
let (commit, dirty) = switch fromCi {
|
|
46
|
+
| Some(_) => (fromCi, false)
|
|
47
|
+
| None =>
|
|
48
|
+
switch git.head() {
|
|
49
|
+
| Some(_) as head => (head, git.dirty())
|
|
50
|
+
| None => (None, false)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
{
|
|
54
|
+
commit,
|
|
55
|
+
dirty,
|
|
56
|
+
environment: get("REVENTLESS_ENVIRONMENT"),
|
|
57
|
+
purpose: get("REVENTLESS_ENVIRONMENT_PURPOSE"),
|
|
58
|
+
tag: get("GITHUB_REF_TYPE") == Some("tag") ? get("GITHUB_REF_NAME") : None,
|
|
59
|
+
pullRequest: get("REVENTLESS_PULL_REQUEST")->Option.flatMap(n => Int.fromString(n)),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let run = (~cwd: string, args: array<string>): option<string> =>
|
|
64
|
+
try {
|
|
65
|
+
Some(
|
|
66
|
+
NodeChildProcess.execFileSync(
|
|
67
|
+
"git",
|
|
68
|
+
args,
|
|
69
|
+
{cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"]},
|
|
70
|
+
)->String.trim,
|
|
71
|
+
)
|
|
72
|
+
} catch {
|
|
73
|
+
| _ => None
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Git in `cwd`. A directory that is not a checkout, or a machine with no git,
|
|
77
|
+
answers *no commit* and *not dirty*. */
|
|
78
|
+
let gitAt = (~cwd: string): git => {
|
|
79
|
+
head: () => run(~cwd, ["rev-parse", "HEAD"])->nonEmpty,
|
|
80
|
+
dirty: () => run(~cwd, ["status", "--porcelain"])->nonEmpty->Option.isSome,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The provenance of the deployment this process is making, in its working directory. */
|
|
84
|
+
let current = (): t => fromEnv(NodeProcess.env, ~git=gitAt(~cwd=NodeProcess.cwd()))
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
6
|
+
import * as Nodechild_process from "node:child_process";
|
|
7
|
+
|
|
8
|
+
function nonEmpty(value) {
|
|
9
|
+
return Stdlib_Option.flatMap(value, v => {
|
|
10
|
+
if (v.trim() === "") {
|
|
11
|
+
return;
|
|
12
|
+
} else {
|
|
13
|
+
return v.trim();
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fromEnv(env, git) {
|
|
19
|
+
let fromCi = Stdlib_Option.orElse(Stdlib_Option.orElse(nonEmpty(env["REVENTLESS_COMMIT"]), nonEmpty(env["GITHUB_SHA"])), nonEmpty(env["CI_COMMIT_SHA"]));
|
|
20
|
+
let match;
|
|
21
|
+
if (fromCi !== undefined) {
|
|
22
|
+
match = [
|
|
23
|
+
fromCi,
|
|
24
|
+
false
|
|
25
|
+
];
|
|
26
|
+
} else {
|
|
27
|
+
let head = git.head();
|
|
28
|
+
match = head !== undefined ? [
|
|
29
|
+
head,
|
|
30
|
+
git.dirty()
|
|
31
|
+
] : [
|
|
32
|
+
undefined,
|
|
33
|
+
false
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
commit: match[0],
|
|
38
|
+
dirty: match[1],
|
|
39
|
+
environment: nonEmpty(env["REVENTLESS_ENVIRONMENT"]),
|
|
40
|
+
purpose: nonEmpty(env["REVENTLESS_ENVIRONMENT_PURPOSE"]),
|
|
41
|
+
tag: Primitive_object.equal(nonEmpty(env["GITHUB_REF_TYPE"]), "tag") ? nonEmpty(env["GITHUB_REF_NAME"]) : undefined,
|
|
42
|
+
pullRequest: Stdlib_Option.flatMap(nonEmpty(env["REVENTLESS_PULL_REQUEST"]), n => Stdlib_Int.fromString(n, undefined))
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function run(cwd, args) {
|
|
47
|
+
try {
|
|
48
|
+
return Nodechild_process.execFileSync("git", args, {
|
|
49
|
+
cwd: cwd,
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
stdio: [
|
|
52
|
+
"ignore",
|
|
53
|
+
"pipe",
|
|
54
|
+
"ignore"
|
|
55
|
+
]
|
|
56
|
+
}).trim();
|
|
57
|
+
} catch (exn) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function gitAt(cwd) {
|
|
63
|
+
return {
|
|
64
|
+
head: () => nonEmpty(run(cwd, [
|
|
65
|
+
"rev-parse",
|
|
66
|
+
"HEAD"
|
|
67
|
+
])),
|
|
68
|
+
dirty: () => Stdlib_Option.isSome(nonEmpty(run(cwd, [
|
|
69
|
+
"status",
|
|
70
|
+
"--porcelain"
|
|
71
|
+
])))
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function current() {
|
|
76
|
+
return fromEnv(process.env, gitAt(process.cwd()));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export {
|
|
80
|
+
nonEmpty,
|
|
81
|
+
fromEnv,
|
|
82
|
+
run,
|
|
83
|
+
gitAt,
|
|
84
|
+
current,
|
|
85
|
+
}
|
|
86
|
+
/* node:child_process Not a pure module */
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/***
|
|
2
|
+
The environments an app deploys to, as a file: `environments.yaml`, beside its
|
|
3
|
+
`deploy-manifest.yaml`.
|
|
4
|
+
|
|
5
|
+
```yaml
|
|
6
|
+
environments:
|
|
7
|
+
- { name: review, purpose: review, pullRequestsInto: dev }
|
|
8
|
+
- { name: dev, purpose: dev, branch: dev }
|
|
9
|
+
- { name: test, purpose: test, branch: test }
|
|
10
|
+
- { name: production, purpose: production, branch: main, tags: "v*", stack: prod }
|
|
11
|
+
- { name: demo, purpose: demo, tags: "v*" }
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
A *shared* environment has one stack and is fed by a branch, by tags, or by both.
|
|
15
|
+
A *review* environment is fed by the pull requests into one branch and has one
|
|
16
|
+
stack per open pull request, named `<name>-pr-<number>`.
|
|
17
|
+
|
|
18
|
+
Here rather than in the AWS package because more than the deploy reads it: a
|
|
19
|
+
tool that shows which commit runs where needs the same answer to *which branch
|
|
20
|
+
feeds which environment* as the workflow that deploys, and a second reading of
|
|
21
|
+
the file is how the two would come to disagree.
|
|
22
|
+
|
|
23
|
+
The file is optional. Without it a deploy uses the branch name as the stack, as
|
|
24
|
+
it always has; nothing here is consulted.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
What an environment is for. `Other` keeps a team's own word rather than refusing
|
|
29
|
+
the file, because a purpose nothing here branches on costs nothing to carry.
|
|
30
|
+
*/
|
|
31
|
+
type purpose =
|
|
32
|
+
| Dev
|
|
33
|
+
| Test
|
|
34
|
+
| Production
|
|
35
|
+
| Demo
|
|
36
|
+
| Review
|
|
37
|
+
| Other(string)
|
|
38
|
+
|
|
39
|
+
let purposeSchema: S.t<purpose> = S.union([
|
|
40
|
+
S.literal("dev")->S.shape(_ => Dev),
|
|
41
|
+
S.literal("test")->S.shape(_ => Test),
|
|
42
|
+
S.literal("production")->S.shape(_ => Production),
|
|
43
|
+
S.literal("demo")->S.shape(_ => Demo),
|
|
44
|
+
S.literal("review")->S.shape(_ => Review),
|
|
45
|
+
S.string->S.shape(other => Other(other)),
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
let purposeName = (purpose: purpose): string =>
|
|
49
|
+
switch purpose {
|
|
50
|
+
| Dev => "dev"
|
|
51
|
+
| Test => "test"
|
|
52
|
+
| Production => "production"
|
|
53
|
+
| Demo => "demo"
|
|
54
|
+
| Review => "review"
|
|
55
|
+
| Other(name) => name
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One entry as the file spells it. [parseString] turns it into an [environment]. */
|
|
59
|
+
@schema
|
|
60
|
+
type entry = {
|
|
61
|
+
name: string,
|
|
62
|
+
purpose: @s.matches(purposeSchema) purpose,
|
|
63
|
+
branch?: string,
|
|
64
|
+
tags?: string,
|
|
65
|
+
stack?: string,
|
|
66
|
+
pullRequestsInto?: string,
|
|
67
|
+
drafts?: bool,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@schema
|
|
71
|
+
type file = {environments: array<entry>}
|
|
72
|
+
|
|
73
|
+
/** What feeds an environment, and so how many stacks it has. */
|
|
74
|
+
type feed =
|
|
75
|
+
| Shared({branch: option<string>, tags: option<string>, stack: string})
|
|
76
|
+
| ReviewOf({into: string, drafts: bool})
|
|
77
|
+
|
|
78
|
+
type environment = {name: string, purpose: purpose, feed: feed}
|
|
79
|
+
|
|
80
|
+
type t = array<environment>
|
|
81
|
+
|
|
82
|
+
// ── Reading ──────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
@module("yaml") external parseYaml: string => JSON.t = "parse"
|
|
85
|
+
@module("yaml") external stringifyYaml: JSON.t => string = "stringify"
|
|
86
|
+
|
|
87
|
+
let environmentOf = (entry: entry): result<environment, string> =>
|
|
88
|
+
switch (entry.pullRequestsInto, entry.branch, entry.tags) {
|
|
89
|
+
| (Some(_), Some(_), _) | (Some(_), _, Some(_)) =>
|
|
90
|
+
Error(`${entry.name}: pullRequestsInto cannot be combined with branch or tags`)
|
|
91
|
+
| (Some(_), None, None) if entry.stack->Option.isSome =>
|
|
92
|
+
Error(`${entry.name}: a review environment names its stacks itself; remove stack`)
|
|
93
|
+
| (Some(into), None, None) =>
|
|
94
|
+
Ok({
|
|
95
|
+
name: entry.name,
|
|
96
|
+
purpose: entry.purpose,
|
|
97
|
+
feed: ReviewOf({into, drafts: entry.drafts->Option.getOr(false)}),
|
|
98
|
+
})
|
|
99
|
+
| (None, None, None) =>
|
|
100
|
+
Error(`${entry.name}: says neither branch, tags nor pullRequestsInto, so nothing feeds it`)
|
|
101
|
+
| (None, _, _) if entry.drafts->Option.isSome =>
|
|
102
|
+
Error(`${entry.name}: drafts is only meaningful with pullRequestsInto`)
|
|
103
|
+
| (None, branch, tags) =>
|
|
104
|
+
Ok({
|
|
105
|
+
name: entry.name,
|
|
106
|
+
purpose: entry.purpose,
|
|
107
|
+
feed: Shared({branch, tags, stack: entry.stack->Option.getOr(entry.name)}),
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Parses an environments document. Strict, like the accounts manifest: a
|
|
112
|
+
malformed entry refuses the whole file, because a skipped environment is a
|
|
113
|
+
branch that silently stops deploying. */
|
|
114
|
+
let parseString = (yamlText: string): result<t, string> =>
|
|
115
|
+
switch try Ok(S.parseOrThrow(parseYaml(yamlText), ~to=fileSchema)) catch {
|
|
116
|
+
| JsExn(err) => Error(JsExn.message(err)->Option.getOr("YAML parse error"))
|
|
117
|
+
| _ => Error("YAML parse error")
|
|
118
|
+
} {
|
|
119
|
+
| Error(_) as e => e
|
|
120
|
+
| Ok({environments}) =>
|
|
121
|
+
let names = environments->Array.map(e => e.name)
|
|
122
|
+
switch names->Array.find(n => names->Array.filter(m => m == n)->Array.length > 1) {
|
|
123
|
+
| Some(duplicate) => Error(`${duplicate}: named twice`)
|
|
124
|
+
| None =>
|
|
125
|
+
environments->Array.reduce(Ok([]), (acc, entry) =>
|
|
126
|
+
switch (acc, environmentOf(entry)) {
|
|
127
|
+
| (Error(_) as e, _) => e
|
|
128
|
+
| (Ok(_), Error(message)) => Error(message)
|
|
129
|
+
| (Ok(done), Ok(environment)) => Ok(done->Array.concat([environment]))
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let parseFile = (path: string): result<t, string> =>
|
|
136
|
+
try {
|
|
137
|
+
parseString(NodeFs.readFileSync(path))
|
|
138
|
+
} catch {
|
|
139
|
+
| JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot read ${path}`))
|
|
140
|
+
| _ => Error(`Cannot read ${path}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let entryOf = (environment: environment): entry =>
|
|
144
|
+
switch environment.feed {
|
|
145
|
+
| Shared({branch, tags, stack}) => {
|
|
146
|
+
name: environment.name,
|
|
147
|
+
purpose: environment.purpose,
|
|
148
|
+
?branch,
|
|
149
|
+
?tags,
|
|
150
|
+
stack: ?(stack == environment.name ? None : Some(stack)),
|
|
151
|
+
}
|
|
152
|
+
| ReviewOf({into, drafts}) => {
|
|
153
|
+
name: environment.name,
|
|
154
|
+
purpose: environment.purpose,
|
|
155
|
+
pullRequestsInto: into,
|
|
156
|
+
drafts: ?(drafts ? Some(true) : None),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The document for a set of environments. Comments are not kept: this writes a
|
|
161
|
+
new file, it does not edit one. */
|
|
162
|
+
let print = (environments: t): string =>
|
|
163
|
+
{environments: environments->Array.map(entryOf)}->Util_Sury.toJson(fileSchema)->stringifyYaml
|
|
164
|
+
|
|
165
|
+
/** Where a deploy looks for it: beside the deploy manifest. */
|
|
166
|
+
let fileName = "environments.yaml"
|
|
167
|
+
|
|
168
|
+
let pathBeside = (~manifest: string): string =>
|
|
169
|
+
NodePath.join([NodePath.dirname(manifest), fileName])
|
|
170
|
+
|
|
171
|
+
// ── Resolving ────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
A branch or tag pattern as GitHub's workflow filters spell them: `*` matches
|
|
175
|
+
within one path segment, `**` across segments. Nothing else is special.
|
|
176
|
+
*/
|
|
177
|
+
let matches = (~pattern: string, name: string): bool => {
|
|
178
|
+
let escaped =
|
|
179
|
+
pattern
|
|
180
|
+
->String.split("**")
|
|
181
|
+
->Array.map(part =>
|
|
182
|
+
part
|
|
183
|
+
->String.replaceRegExp(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
184
|
+
->String.replaceAll("*", "[^/]*")
|
|
185
|
+
)
|
|
186
|
+
->Array.join(".*")
|
|
187
|
+
RegExp.fromString(`^${escaped}$`)->RegExp.test(name)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What a push or a tag deploys to. */
|
|
191
|
+
type ref_ =
|
|
192
|
+
| Branch(string)
|
|
193
|
+
| Tag(string)
|
|
194
|
+
|
|
195
|
+
/** `refs/heads/test` and `refs/tags/v1`; anything else is not deployable. */
|
|
196
|
+
let refOf = (ref: string): option<ref_> =>
|
|
197
|
+
if ref->String.startsWith("refs/heads/") {
|
|
198
|
+
Some(Branch(ref->String.slice(~start=11)))
|
|
199
|
+
} else if ref->String.startsWith("refs/tags/") {
|
|
200
|
+
Some(Tag(ref->String.slice(~start=10)))
|
|
201
|
+
} else {
|
|
202
|
+
None
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
type resolved = {name: string, stack: string, purpose: purpose}
|
|
206
|
+
|
|
207
|
+
/** The first shared environment, in file order, that the branch or tag feeds. */
|
|
208
|
+
let resolve = (environments: t, ref: ref_): option<resolved> =>
|
|
209
|
+
environments->Array.findMap(({name, purpose, feed}) =>
|
|
210
|
+
switch (feed, ref) {
|
|
211
|
+
| (Shared({branch: Some(pattern), stack}), Branch(branch)) if matches(~pattern, branch) =>
|
|
212
|
+
Some({name, stack, purpose})
|
|
213
|
+
| (Shared({tags: Some(pattern), stack}), Tag(tag)) if matches(~pattern, tag) =>
|
|
214
|
+
Some({name, stack, purpose})
|
|
215
|
+
| (Shared(_), _) | (ReviewOf(_), _) => None
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
/** The review stack of one pull request, if the branch it targets has a review
|
|
220
|
+
environment. `draft` pull requests get one only where the environment asks. */
|
|
221
|
+
let resolvePullRequest = (environments: t, ~base: string, ~number: int, ~draft: bool): option<
|
|
222
|
+
resolved,
|
|
223
|
+
> =>
|
|
224
|
+
environments->Array.findMap(({name, purpose, feed}) =>
|
|
225
|
+
switch feed {
|
|
226
|
+
| ReviewOf({into, drafts}) if matches(~pattern=into, base) && (drafts || !draft) =>
|
|
227
|
+
Some({name, stack: `${name}-pr-${number->Int.toString}`, purpose})
|
|
228
|
+
| ReviewOf(_) | Shared(_) => None
|
|
229
|
+
}
|
|
230
|
+
)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Sury from "sury";
|
|
4
|
+
import * as Yaml from "yaml";
|
|
5
|
+
import * as Nodefs from "node:fs";
|
|
6
|
+
import * as Nodepath from "node:path";
|
|
7
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
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_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
11
|
+
import * as Util_Sury$Reventless from "../util/Util_Sury.res.mjs";
|
|
12
|
+
|
|
13
|
+
let purposeSchema = Sury.union([
|
|
14
|
+
Sury.shape(Sury.literal("dev"), param => "Dev"),
|
|
15
|
+
Sury.shape(Sury.literal("test"), param => "Test"),
|
|
16
|
+
Sury.shape(Sury.literal("production"), param => "Production"),
|
|
17
|
+
Sury.shape(Sury.literal("demo"), param => "Demo"),
|
|
18
|
+
Sury.shape(Sury.literal("review"), param => "Review"),
|
|
19
|
+
Sury.shape(Sury.string, other => ({
|
|
20
|
+
TAG: "Other",
|
|
21
|
+
_0: other
|
|
22
|
+
}))
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function purposeName(purpose) {
|
|
26
|
+
if (typeof purpose === "object") {
|
|
27
|
+
return purpose._0;
|
|
28
|
+
}
|
|
29
|
+
switch (purpose) {
|
|
30
|
+
case "Dev" :
|
|
31
|
+
return "dev";
|
|
32
|
+
case "Test" :
|
|
33
|
+
return "test";
|
|
34
|
+
case "Production" :
|
|
35
|
+
return "production";
|
|
36
|
+
case "Demo" :
|
|
37
|
+
return "demo";
|
|
38
|
+
case "Review" :
|
|
39
|
+
return "review";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let entrySchema = Sury.$schema(s => ({
|
|
44
|
+
name: s.m(Sury.string),
|
|
45
|
+
purpose: s.m(purposeSchema),
|
|
46
|
+
branch: s.m(Sury.$option(Sury.string)),
|
|
47
|
+
tags: s.m(Sury.$option(Sury.string)),
|
|
48
|
+
stack: s.m(Sury.$option(Sury.string)),
|
|
49
|
+
pullRequestsInto: s.m(Sury.$option(Sury.string)),
|
|
50
|
+
drafts: s.m(Sury.$option(Sury.boolean))
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
let fileSchema = Sury.$schema(s => ({
|
|
54
|
+
environments: s.m(Sury.array(entrySchema))
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
function environmentOf(entry) {
|
|
58
|
+
let match = entry.pullRequestsInto;
|
|
59
|
+
let match$1 = entry.branch;
|
|
60
|
+
let match$2 = entry.tags;
|
|
61
|
+
if (match !== undefined) {
|
|
62
|
+
if (match$1 === undefined && match$2 === undefined) {
|
|
63
|
+
if (Stdlib_Option.isSome(entry.stack)) {
|
|
64
|
+
return {
|
|
65
|
+
TAG: "Error",
|
|
66
|
+
_0: entry.name + `: a review environment names its stacks itself; remove stack`
|
|
67
|
+
};
|
|
68
|
+
} else {
|
|
69
|
+
return {
|
|
70
|
+
TAG: "Ok",
|
|
71
|
+
_0: {
|
|
72
|
+
name: entry.name,
|
|
73
|
+
purpose: entry.purpose,
|
|
74
|
+
feed: {
|
|
75
|
+
TAG: "ReviewOf",
|
|
76
|
+
into: match,
|
|
77
|
+
drafts: Stdlib_Option.getOr(entry.drafts, false)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
let exit = 0;
|
|
85
|
+
if (match$1 !== undefined) {
|
|
86
|
+
exit = 2;
|
|
87
|
+
} else {
|
|
88
|
+
if (match$2 === undefined) {
|
|
89
|
+
return {
|
|
90
|
+
TAG: "Error",
|
|
91
|
+
_0: entry.name + `: says neither branch, tags nor pullRequestsInto, so nothing feeds it`
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
exit = 2;
|
|
95
|
+
}
|
|
96
|
+
if (exit === 2) {
|
|
97
|
+
if (Stdlib_Option.isSome(entry.drafts)) {
|
|
98
|
+
return {
|
|
99
|
+
TAG: "Error",
|
|
100
|
+
_0: entry.name + `: drafts is only meaningful with pullRequestsInto`
|
|
101
|
+
};
|
|
102
|
+
} else {
|
|
103
|
+
return {
|
|
104
|
+
TAG: "Ok",
|
|
105
|
+
_0: {
|
|
106
|
+
name: entry.name,
|
|
107
|
+
purpose: entry.purpose,
|
|
108
|
+
feed: {
|
|
109
|
+
TAG: "Shared",
|
|
110
|
+
branch: match$1,
|
|
111
|
+
tags: match$2,
|
|
112
|
+
stack: Stdlib_Option.getOr(entry.stack, entry.name)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
TAG: "Error",
|
|
121
|
+
_0: entry.name + `: pullRequestsInto cannot be combined with branch or tags`
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseString(yamlText) {
|
|
126
|
+
let e;
|
|
127
|
+
try {
|
|
128
|
+
e = {
|
|
129
|
+
TAG: "Ok",
|
|
130
|
+
_0: Sury.parseOrThrow(Yaml.parse(yamlText), fileSchema)
|
|
131
|
+
};
|
|
132
|
+
} catch (raw_err) {
|
|
133
|
+
let err = Primitive_exceptions.internalToException(raw_err);
|
|
134
|
+
e = err.RE_EXN_ID === "JsExn" ? ({
|
|
135
|
+
TAG: "Error",
|
|
136
|
+
_0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "YAML parse error")
|
|
137
|
+
}) : ({
|
|
138
|
+
TAG: "Error",
|
|
139
|
+
_0: "YAML parse error"
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (e.TAG !== "Ok") {
|
|
143
|
+
return e;
|
|
144
|
+
}
|
|
145
|
+
let environments = e._0.environments;
|
|
146
|
+
let names = environments.map(e => e.name);
|
|
147
|
+
let duplicate = names.find(n => names.filter(m => m === n).length > 1);
|
|
148
|
+
if (duplicate !== undefined) {
|
|
149
|
+
return {
|
|
150
|
+
TAG: "Error",
|
|
151
|
+
_0: duplicate + `: named twice`
|
|
152
|
+
};
|
|
153
|
+
} else {
|
|
154
|
+
return Stdlib_Array.reduce(environments, {
|
|
155
|
+
TAG: "Ok",
|
|
156
|
+
_0: []
|
|
157
|
+
}, (acc, entry) => {
|
|
158
|
+
let match = environmentOf(entry);
|
|
159
|
+
if (acc.TAG === "Ok") {
|
|
160
|
+
if (match.TAG === "Ok") {
|
|
161
|
+
return {
|
|
162
|
+
TAG: "Ok",
|
|
163
|
+
_0: acc._0.concat([match._0])
|
|
164
|
+
};
|
|
165
|
+
} else {
|
|
166
|
+
return {
|
|
167
|
+
TAG: "Error",
|
|
168
|
+
_0: match._0
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
return acc;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseFile(path) {
|
|
179
|
+
try {
|
|
180
|
+
return parseString(Nodefs.readFileSync(path, "utf8"));
|
|
181
|
+
} catch (raw_err) {
|
|
182
|
+
let err = Primitive_exceptions.internalToException(raw_err);
|
|
183
|
+
if (err.RE_EXN_ID === "JsExn") {
|
|
184
|
+
return {
|
|
185
|
+
TAG: "Error",
|
|
186
|
+
_0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), `Cannot read ` + path)
|
|
187
|
+
};
|
|
188
|
+
} else {
|
|
189
|
+
return {
|
|
190
|
+
TAG: "Error",
|
|
191
|
+
_0: `Cannot read ` + path
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function entryOf(environment) {
|
|
198
|
+
let match = environment.feed;
|
|
199
|
+
if (match.TAG !== "Shared") {
|
|
200
|
+
return {
|
|
201
|
+
name: environment.name,
|
|
202
|
+
purpose: environment.purpose,
|
|
203
|
+
pullRequestsInto: match.into,
|
|
204
|
+
drafts: match.drafts ? true : undefined
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
let stack = match.stack;
|
|
208
|
+
return {
|
|
209
|
+
name: environment.name,
|
|
210
|
+
purpose: environment.purpose,
|
|
211
|
+
branch: match.branch,
|
|
212
|
+
tags: match.tags,
|
|
213
|
+
stack: stack === environment.name ? undefined : stack
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function print(environments) {
|
|
218
|
+
return Yaml.stringify(Util_Sury$Reventless.toJson({
|
|
219
|
+
environments: environments.map(entryOf)
|
|
220
|
+
}, fileSchema));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let fileName = "environments.yaml";
|
|
224
|
+
|
|
225
|
+
function pathBeside(manifest) {
|
|
226
|
+
return Nodepath.join(Nodepath.dirname(manifest), fileName);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function matches(pattern, name) {
|
|
230
|
+
let escaped = pattern.split("**").map(part => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*")).join(".*");
|
|
231
|
+
return new RegExp(`^` + escaped + `$`).test(name);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function refOf(ref) {
|
|
235
|
+
if (ref.startsWith("refs/heads/")) {
|
|
236
|
+
return {
|
|
237
|
+
TAG: "Branch",
|
|
238
|
+
_0: ref.slice(11)
|
|
239
|
+
};
|
|
240
|
+
} else if (ref.startsWith("refs/tags/")) {
|
|
241
|
+
return {
|
|
242
|
+
TAG: "Tag",
|
|
243
|
+
_0: ref.slice(10)
|
|
244
|
+
};
|
|
245
|
+
} else {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resolve(environments, ref) {
|
|
251
|
+
return Stdlib_Array.findMap(environments, param => {
|
|
252
|
+
let feed = param.feed;
|
|
253
|
+
let purpose = param.purpose;
|
|
254
|
+
let name = param.name;
|
|
255
|
+
if (feed.TAG !== "Shared") {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
let pattern = feed.branch;
|
|
259
|
+
if (pattern !== undefined && ref.TAG === "Branch") {
|
|
260
|
+
if (matches(pattern, ref._0)) {
|
|
261
|
+
return {
|
|
262
|
+
name: name,
|
|
263
|
+
stack: feed.stack,
|
|
264
|
+
purpose: purpose
|
|
265
|
+
};
|
|
266
|
+
} else {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
let pattern$1 = feed.tags;
|
|
271
|
+
if (pattern$1 !== undefined && ref.TAG !== "Branch" && matches(pattern$1, ref._0)) {
|
|
272
|
+
return {
|
|
273
|
+
name: name,
|
|
274
|
+
stack: feed.stack,
|
|
275
|
+
purpose: purpose
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resolvePullRequest(environments, base, number, draft) {
|
|
282
|
+
return Stdlib_Array.findMap(environments, param => {
|
|
283
|
+
let feed = param.feed;
|
|
284
|
+
let name = param.name;
|
|
285
|
+
if (feed.TAG === "Shared" || !(matches(feed.into, base) && (feed.drafts || !draft))) {
|
|
286
|
+
return;
|
|
287
|
+
} else {
|
|
288
|
+
return {
|
|
289
|
+
name: name,
|
|
290
|
+
stack: name + `-pr-` + number.toString(),
|
|
291
|
+
purpose: param.purpose
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export {
|
|
298
|
+
purposeSchema,
|
|
299
|
+
purposeName,
|
|
300
|
+
entrySchema,
|
|
301
|
+
fileSchema,
|
|
302
|
+
environmentOf,
|
|
303
|
+
parseString,
|
|
304
|
+
parseFile,
|
|
305
|
+
entryOf,
|
|
306
|
+
print,
|
|
307
|
+
fileName,
|
|
308
|
+
pathBeside,
|
|
309
|
+
matches,
|
|
310
|
+
refOf,
|
|
311
|
+
resolve,
|
|
312
|
+
resolvePullRequest,
|
|
313
|
+
}
|
|
314
|
+
/* purposeSchema Not a pure module */
|
package/src/types/Message.res
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
@schema
|
|
3
3
|
type service = string
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
The issuing caller, classified for `@owner` enforcement (`OwnerScope.toClaim`).
|
|
7
|
+
|
|
8
|
+
Written only by the command generator, from the authenticated identity, so a
|
|
9
|
+
handler can tell an owner from an operator without seeing the caller's groups.
|
|
10
|
+
Absent on every command that did not come through the generator — an
|
|
11
|
+
automation's follow-up, an internal dispatch — which a handler reads as the
|
|
12
|
+
platform acting for itself.
|
|
13
|
+
*/
|
|
14
|
+
module CallerClaim = {
|
|
15
|
+
@schema
|
|
16
|
+
type t =
|
|
17
|
+
| Owned({userId: string})
|
|
18
|
+
| Exempt
|
|
19
|
+
| Unidentified
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
/**
|
|
6
23
|
Envelope metadata attached to every event and command.
|
|
7
24
|
|
|
@@ -50,6 +67,10 @@ type meta = {
|
|
|
50
67
|
/** Extensible header bag for cross-cutting context (tenantId, feature flags, etc.).
|
|
51
68
|
Absent when empty — consumers normalise with `->Option.getOr(Dict.make())`. */
|
|
52
69
|
headers?: dict<string>,
|
|
70
|
+
/** Commands only: the caller as the generator classified it. Deliberately not
|
|
71
|
+
inherited by `deriveMeta`, so an event or a follow-up command never carries
|
|
72
|
+
the claim of the command that caused it. */
|
|
73
|
+
callerClaim?: CallerClaim.t,
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
/**
|
|
@@ -6,6 +6,19 @@ import * as Util_Sury$Reventless from "../util/Util_Sury.res.mjs";
|
|
|
6
6
|
|
|
7
7
|
let serviceSchema = Sury.string;
|
|
8
8
|
|
|
9
|
+
let schema = Sury.union([
|
|
10
|
+
Sury.$schema(s => ({
|
|
11
|
+
TAG: "Owned",
|
|
12
|
+
userId: s.m(Sury.string)
|
|
13
|
+
})),
|
|
14
|
+
Sury.literal("Exempt"),
|
|
15
|
+
Sury.literal("Unidentified")
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
let CallerClaim = {
|
|
19
|
+
schema: schema
|
|
20
|
+
};
|
|
21
|
+
|
|
9
22
|
let metaSchema = Sury.$schema(s => ({
|
|
10
23
|
service: s.m(serviceSchema),
|
|
11
24
|
time: s.m(Sury.string),
|
|
@@ -16,7 +29,8 @@ let metaSchema = Sury.$schema(s => ({
|
|
|
16
29
|
causationId: s.m(Sury.$option(Sury.string)),
|
|
17
30
|
traceparent: s.m(Sury.$option(Sury.string)),
|
|
18
31
|
schemaVersion: s.m(Sury.$option(Sury.string)),
|
|
19
|
-
headers: s.m(Sury.$option(Sury.record(Sury.string)))
|
|
32
|
+
headers: s.m(Sury.$option(Sury.record(Sury.string))),
|
|
33
|
+
callerClaim: s.m(Sury.$option(schema))
|
|
20
34
|
}));
|
|
21
35
|
|
|
22
36
|
let contextSchema = Sury.$schema(s => ({
|
|
@@ -189,6 +203,7 @@ function composeEventJson$p(id, meta, eventJson) {
|
|
|
189
203
|
|
|
190
204
|
export {
|
|
191
205
|
serviceSchema,
|
|
206
|
+
CallerClaim,
|
|
192
207
|
metaSchema,
|
|
193
208
|
contextSchema,
|
|
194
209
|
statusChangeSchema,
|
package/src/types/OwnerScope.res
CHANGED
|
@@ -207,6 +207,78 @@ let isExempt = (scope: t): bool =>
|
|
|
207
207
|
| Owned(_) | Unidentified(_) => false
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
The classification as it travels on a command's envelope.
|
|
212
|
+
|
|
213
|
+
`System` and `Elevated` collapse into `Exempt` here, unlike in `t`: the handler
|
|
214
|
+
that reads the claim only asks whether the caller may act for anyone, and the
|
|
215
|
+
audit distinction is already on the envelope as `meta.user`.
|
|
216
|
+
*/
|
|
217
|
+
let toClaim = (scope: t): Message.CallerClaim.t =>
|
|
218
|
+
switch scope {
|
|
219
|
+
| Owned({userId}) => Owned({userId: userId})
|
|
220
|
+
| System | Elevated(_) => Exempt
|
|
221
|
+
| Unidentified(_) => Unidentified
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
Whether a command may act on a partition whose history records an owner.
|
|
226
|
+
|
|
227
|
+
`ConflictingOwners` comes first and holds for every caller: two owners in one
|
|
228
|
+
partition is a data defect, and picking either would be guessing whose thing it
|
|
229
|
+
is. `CallerUnidentified` holds even on an empty history — a creating command
|
|
230
|
+
from nobody has no owner to stamp.
|
|
231
|
+
*/
|
|
232
|
+
type acting =
|
|
233
|
+
| MayAct
|
|
234
|
+
| NotTheOwner({owner: string})
|
|
235
|
+
| CallerUnidentified
|
|
236
|
+
| ConflictingOwners(array<string>)
|
|
237
|
+
| OwnerUnreadable
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
Apply the rule to a command's claim and the distinct owners its partition records.
|
|
241
|
+
|
|
242
|
+
`recordedOwners: None` says the handler could not name the partition the command
|
|
243
|
+
acts on, so it could not read who owns it. That refuses an owned caller rather
|
|
244
|
+
than reading as "no owner yet", which would let anyone act.
|
|
245
|
+
|
|
246
|
+
A missing claim is the platform acting for itself: the generator writes one on
|
|
247
|
+
every command it issues, so a command without one reached the handler by an
|
|
248
|
+
internal route — the same routes `@authorize` already trusts.
|
|
249
|
+
*/
|
|
250
|
+
let decideActing = (
|
|
251
|
+
~claim: option<Message.CallerClaim.t>,
|
|
252
|
+
~recordedOwners: option<array<string>>,
|
|
253
|
+
): acting =>
|
|
254
|
+
switch (recordedOwners, claim) {
|
|
255
|
+
| (Some(owners), _) if owners->Array.length > 1 => ConflictingOwners(owners)
|
|
256
|
+
| (_, None | Some(Exempt)) => MayAct
|
|
257
|
+
| (_, Some(Unidentified)) => CallerUnidentified
|
|
258
|
+
| (None, Some(Owned(_))) => OwnerUnreadable
|
|
259
|
+
| (Some(owners), Some(Owned({userId}))) =>
|
|
260
|
+
switch owners[0] {
|
|
261
|
+
| Some(owner) if owner != userId => NotTheOwner({owner: owner})
|
|
262
|
+
| Some(_) | None => MayAct
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
Add the owners one recorded event names to the ones already folded.
|
|
268
|
+
|
|
269
|
+
`fields` are that event type's `@owner` fields (`Owner.fieldNamesByVariant`).
|
|
270
|
+
Distinct, so a partition whose every event repeats the same owner folds to one.
|
|
271
|
+
*/
|
|
272
|
+
let recordOwners = (owners: array<string>, ~fields: array<string>, data: dict<JSON.t>): array<
|
|
273
|
+
string,
|
|
274
|
+
> =>
|
|
275
|
+
fields->Array.reduce(owners, (acc, field) =>
|
|
276
|
+
switch data->Dict.get(field)->Option.flatMap(JSON.Decode.string) {
|
|
277
|
+
| Some(owner) if !(acc->Array.includes(owner)) => acc->Array.concat([owner])
|
|
278
|
+
| _ => acc
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
|
|
210
282
|
/**
|
|
211
283
|
What owner scoping does to one read of one view.
|
|
212
284
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
5
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
6
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
6
7
|
import * as Identity$Reventless from "./Identity.res.mjs";
|
|
@@ -133,6 +134,75 @@ function isExempt(scope) {
|
|
|
133
134
|
}
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
function toClaim(scope) {
|
|
138
|
+
if (typeof scope !== "object") {
|
|
139
|
+
return "Exempt";
|
|
140
|
+
}
|
|
141
|
+
switch (scope.TAG) {
|
|
142
|
+
case "Elevated" :
|
|
143
|
+
return "Exempt";
|
|
144
|
+
case "Owned" :
|
|
145
|
+
return {
|
|
146
|
+
TAG: "Owned",
|
|
147
|
+
userId: scope.userId
|
|
148
|
+
};
|
|
149
|
+
case "Unidentified" :
|
|
150
|
+
return "Unidentified";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function decideActing(claim, recordedOwners) {
|
|
155
|
+
if (recordedOwners !== undefined) {
|
|
156
|
+
if (recordedOwners.length > 1) {
|
|
157
|
+
return {
|
|
158
|
+
TAG: "ConflictingOwners",
|
|
159
|
+
_0: recordedOwners
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (claim !== undefined) {
|
|
163
|
+
if (typeof claim !== "object") {
|
|
164
|
+
claim === "Exempt";
|
|
165
|
+
} else {
|
|
166
|
+
let owner = recordedOwners[0];
|
|
167
|
+
if (owner !== undefined && owner !== claim.userId) {
|
|
168
|
+
return {
|
|
169
|
+
TAG: "NotTheOwner",
|
|
170
|
+
owner: owner
|
|
171
|
+
};
|
|
172
|
+
} else {
|
|
173
|
+
return "MayAct";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} else if (claim !== undefined) {
|
|
178
|
+
if (typeof claim === "object") {
|
|
179
|
+
return "OwnerUnreadable";
|
|
180
|
+
}
|
|
181
|
+
claim === "Exempt";
|
|
182
|
+
}
|
|
183
|
+
if (claim === undefined) {
|
|
184
|
+
return "MayAct";
|
|
185
|
+
}
|
|
186
|
+
if (typeof claim !== "object") {
|
|
187
|
+
if (claim === "Exempt") {
|
|
188
|
+
return "MayAct";
|
|
189
|
+
} else {
|
|
190
|
+
return "CallerUnidentified";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function recordOwners(owners, fields, data) {
|
|
196
|
+
return Stdlib_Array.reduce(fields, owners, (acc, field) => {
|
|
197
|
+
let owner = Stdlib_Option.flatMap(data[field], Stdlib_JSON.Decode.string);
|
|
198
|
+
if (owner !== undefined && !acc.includes(owner)) {
|
|
199
|
+
return acc.concat([owner]);
|
|
200
|
+
} else {
|
|
201
|
+
return acc;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
136
206
|
function decide(identity, ownerField, elevatedOpt) {
|
|
137
207
|
let elevated = elevatedOpt !== undefined ? elevatedOpt : elevatedGroups();
|
|
138
208
|
if (ownerField === undefined) {
|
|
@@ -244,6 +314,9 @@ export {
|
|
|
244
314
|
resolve,
|
|
245
315
|
ownerId,
|
|
246
316
|
isExempt,
|
|
317
|
+
toClaim,
|
|
318
|
+
decideActing,
|
|
319
|
+
recordOwners,
|
|
247
320
|
decide,
|
|
248
321
|
scopeOf,
|
|
249
322
|
decideRetired,
|