@reventlessdev/reventless-core 3.0.0-alpha.233 → 3.0.0-alpha.234

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 CHANGED
@@ -3,6 +3,13 @@
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.234 (2026-08-14)
7
+
8
+ ### Features
9
+
10
+ * **core:** one contract for which refusal a platform gave ([7705c13](https://github.com/ReventlessDev/reventless-core/commit/7705c13ec63f41a3a659bc98cfb45b880fd5b222))
11
+
12
+
6
13
  # 3.0.0-alpha.233 (2026-08-14)
7
14
 
8
15
  **Note:** Version bump only for package @reventlessdev/reventless-core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.233",
3
+ "version": "3.0.0-alpha.234",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,16 +29,16 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
33
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
34
32
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.6",
33
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
34
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
35
35
  "@reventlessdev/rescript-node": "2.0.0-alpha.6",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
37
- "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
38
37
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.6",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.140",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.113",
41
- "@reventlessdev/reventless-interop": "3.0.0-alpha.31"
38
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.141",
39
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
40
+ "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.113"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -0,0 +1,168 @@
1
+ // The cross-provider contract for "which refusal did I just get" — see
2
+ // [docs/plans/appsync-refusal-vocabulary.md].
3
+ //
4
+ // An authorization gate has two ways to refuse and they ask the caller for
5
+ // opposite things. A caller whose credentials did not verify should present new
6
+ // ones. A caller who is simply not entitled will meet the same answer however
7
+ // many times they authenticate — asking them to sign in again ends a working
8
+ // session and fixes nothing.
9
+ //
10
+ // Both adapters draw that distinction. They express it in shapes that have
11
+ // nothing in common, because neither chose its own vocabulary: the local adapter
12
+ // sets `extensions.code` on a GraphQL error, and AppSync refuses at two
13
+ // different layers with `errorType` values the service picks. A client written
14
+ // against one is quietly wrong against the other — it reads for a key that is
15
+ // never present, finds nothing, and proceeds as though nothing was refused.
16
+ //
17
+ // So this module is a mapping, not an equality. It cannot make the two paths
18
+ // answer alike; `extensions` is not even reachable from an AppSync resolver.
19
+ // What it can do is let a caller ask the one question that matters — *was this
20
+ // about entitlement or about identity?* — and get an answer without knowing
21
+ // which adapter it is talking to.
22
+ //
23
+ // Every row here is an observed response, captured against a deployed API and
24
+ // the local server, not a reading of documentation.
25
+
26
+ /**
27
+ Which of the two refusals a response carries.
28
+
29
+ `Entitlement` — the caller was identified and is not permitted. Re-authenticating
30
+ changes nothing; the session is still good.
31
+
32
+ `Identity` — the caller was not identified. New credentials are the remedy, and
33
+ discarding the session is correct here and only here.
34
+ */
35
+ type refusalKind =
36
+ | Entitlement
37
+ | Identity
38
+
39
+ /** The adapter a signal belongs to. */
40
+ type adapter =
41
+ | Local
42
+ | AppSync
43
+
44
+ /**
45
+ One observed refusal shape.
46
+
47
+ `discriminator` names where a client looks; `value` is what it finds there.
48
+ Together with `httpStatus` they are sufficient to classify a response — see
49
+ [classify], which is what callers should use rather than re-deriving this.
50
+ */
51
+ type signal = {
52
+ adapter: adapter,
53
+ kind: refusalKind,
54
+ httpStatus: int,
55
+ discriminator: string,
56
+ value: string,
57
+ }
58
+
59
+ /**
60
+ The `extensions.code` the local adapter sets for each refusal.
61
+
62
+ Exported so the adapter emits these rather than repeating the literals: a table
63
+ the emitting code does not read is documentation, and documentation drifts. The
64
+ AppSync values have no equivalent here — the service picks those, which is the
65
+ whole reason this module is a mapping rather than a shared constant.
66
+ */
67
+ let localEntitlementCode = "FORBIDDEN"
68
+
69
+ /** See [localEntitlementCode]. */
70
+ let localIdentityCode = "UNAUTHORIZED"
71
+
72
+ /**
73
+ The correspondence both adapters are held to.
74
+
75
+ 🚨 **The two AppSync values differ by a suffix and mean opposite things.**
76
+ `Unauthorized` is the entitlement refusal; `UnauthorizedException` is the identity
77
+ one. A client matching on the substring `"Unauthorized"` — the obvious thing to
78
+ write — collapses exactly the distinction this table exists to preserve, and
79
+ collapses it in the dangerous direction: it treats "you lack the role" as "your
80
+ session is dead" and signs the caller out of a session that was working.
81
+
82
+ Use [classify]. It is the only reason this table is data rather than prose.
83
+ */
84
+ let signals: array<signal> = [
85
+ {
86
+ adapter: Local,
87
+ kind: Entitlement,
88
+ httpStatus: 200,
89
+ discriminator: "extensions.code",
90
+ value: localEntitlementCode,
91
+ },
92
+ {
93
+ adapter: Local,
94
+ kind: Identity,
95
+ httpStatus: 200,
96
+ discriminator: "extensions.code",
97
+ value: localIdentityCode,
98
+ },
99
+ {
100
+ adapter: AppSync,
101
+ kind: Entitlement,
102
+ httpStatus: 200,
103
+ discriminator: "errors[].errorType",
104
+ value: "Unauthorized",
105
+ },
106
+ {
107
+ // The request never becomes a GraphQL execution: the service rejects it
108
+ // before the schema is reached, so there is no field error to read and the
109
+ // status line is the whole answer.
110
+ adapter: AppSync,
111
+ kind: Identity,
112
+ httpStatus: 401,
113
+ discriminator: "x-amzn-errortype",
114
+ value: "UnauthorizedException",
115
+ },
116
+ ]
117
+
118
+ /**
119
+ Classify a refusal without knowing which adapter produced it.
120
+
121
+ Pass what the response actually carried: its status, the `errorType` of a GraphQL
122
+ error if there was one, and `extensions.code` if there was one. Returns `None`
123
+ when the response is not a refusal this contract recognises — which includes a
124
+ successful response, so a caller must not read `None` as "allowed".
125
+
126
+ Ordering matters and is the point of the function. The transport status is
127
+ checked first, because a 401 is the identity refusal on the AppSync path and its
128
+ `x-amzn-errortype` (`UnauthorizedException`) shares a prefix with the entitlement
129
+ value (`Unauthorized`). Matching the error type first would classify every
130
+ unidentified AppSync caller as merely unentitled.
131
+ */
132
+ let classify = (
133
+ ~httpStatus: int,
134
+ ~errorType: option<string>=?,
135
+ ~extensionsCode: option<string>=?,
136
+ ): option<refusalKind> =>
137
+ if httpStatus == 401 {
138
+ Some(Identity)
139
+ } else {
140
+ switch extensionsCode {
141
+ | Some(c) if c == localEntitlementCode => Some(Entitlement)
142
+ | Some(c) if c == localIdentityCode => Some(Identity)
143
+ | _ =>
144
+ switch errorType {
145
+ // Exact match, never a prefix or `includes` — see the warning on [signals].
146
+ | Some("Unauthorized") => Some(Entitlement)
147
+ | Some("UnauthorizedException") => Some(Identity)
148
+ | _ => None
149
+ }
150
+ }
151
+ }
152
+
153
+ /** The signals one adapter can produce. */
154
+ let signalsFor = (adapter: adapter): array<signal> =>
155
+ signals->Array.filter(s => s.adapter == adapter)
156
+
157
+ /**
158
+ Whether a refusal means the caller should be asked to authenticate again.
159
+
160
+ The one decision the distinction exists to drive, kept here so no client has to
161
+ re-derive it — and so the wrong answer cannot be arrived at independently in
162
+ several places.
163
+ */
164
+ let warrantsReauthentication = (kind: refusalKind): bool =>
165
+ switch kind {
166
+ | Identity => true
167
+ | Entitlement => false
168
+ }
@@ -0,0 +1,80 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ let localEntitlementCode = "FORBIDDEN";
5
+
6
+ let localIdentityCode = "UNAUTHORIZED";
7
+
8
+ let signals = [
9
+ {
10
+ adapter: "Local",
11
+ kind: "Entitlement",
12
+ httpStatus: 200,
13
+ discriminator: "extensions.code",
14
+ value: localEntitlementCode
15
+ },
16
+ {
17
+ adapter: "Local",
18
+ kind: "Identity",
19
+ httpStatus: 200,
20
+ discriminator: "extensions.code",
21
+ value: localIdentityCode
22
+ },
23
+ {
24
+ adapter: "AppSync",
25
+ kind: "Entitlement",
26
+ httpStatus: 200,
27
+ discriminator: "errors[].errorType",
28
+ value: "Unauthorized"
29
+ },
30
+ {
31
+ adapter: "AppSync",
32
+ kind: "Identity",
33
+ httpStatus: 401,
34
+ discriminator: "x-amzn-errortype",
35
+ value: "UnauthorizedException"
36
+ }
37
+ ];
38
+
39
+ function classify(httpStatus, errorType, extensionsCode) {
40
+ if (httpStatus === 401) {
41
+ return "Identity";
42
+ }
43
+ if (extensionsCode !== undefined) {
44
+ if (extensionsCode === localEntitlementCode) {
45
+ return "Entitlement";
46
+ }
47
+ if (extensionsCode === localIdentityCode) {
48
+ return "Identity";
49
+ }
50
+ }
51
+ if (errorType === undefined) {
52
+ return;
53
+ }
54
+ switch (errorType) {
55
+ case "Unauthorized" :
56
+ return "Entitlement";
57
+ case "UnauthorizedException" :
58
+ return "Identity";
59
+ default:
60
+ return;
61
+ }
62
+ }
63
+
64
+ function signalsFor(adapter) {
65
+ return signals.filter(s => s.adapter === adapter);
66
+ }
67
+
68
+ function warrantsReauthentication(kind) {
69
+ return kind !== "Entitlement";
70
+ }
71
+
72
+ export {
73
+ localEntitlementCode,
74
+ localIdentityCode,
75
+ signals,
76
+ classify,
77
+ signalsFor,
78
+ warrantsReauthentication,
79
+ }
80
+ /* No side effect */
@@ -895,7 +895,7 @@ module Make = (
895
895
  //
896
896
  // Stage E2: a DCB StateChangeSlice has a single GraphQL field but its
897
897
  // command type may declare multiple constructors with different
898
- // authorization rules. The @aws_auth directive operates at field
898
+ // authorization rules. The Cognito group directive operates at field
899
899
  // granularity, so we read the auth for the first constructor (matching
900
900
  // the existing dcbTags convention at line 549 above). When all
901
901
  // constructors share the file-level default, this is exact; when they
@@ -17,7 +17,7 @@ type command =
17
17
  | @noApi Connect(pluginDefinition)
18
18
  | @noApi Disconnect(version)
19
19
  // Admin lifecycle commands — API-exposed (auto-derived admin mutations,
20
- // Cognito @aws_auth gated). `version` selects which known version to act on.
20
+ // Cognito group-gated). `version` selects which known version to act on.
21
21
  | Activate(version)
22
22
  | Deactivate(version)
23
23
  // Records a protocol-version incompatibility without changing connection state.
@@ -0,0 +1,93 @@
1
+ open JestGlobals
2
+
3
+ // The contract in Auth_RefusalVocabulary is a mapping between two adapters that
4
+ // cannot answer alike. The AWS rows cannot be asserted from a checkout — they
5
+ // need a deployed API — so what is asserted here is the *table*: that it names
6
+ // both adapters, that entitlement stays distinguishable from identity in each,
7
+ // and that `classify` resolves the collision the two AppSync values create.
8
+ //
9
+ // Observed bodies behind these rows: docs/plans/appsync-group-authorization-unenforced.md §8.
10
+
11
+ open ReventlessCore.Auth_RefusalVocabulary
12
+
13
+ describe("Auth_RefusalVocabulary — the table", () => {
14
+ testSync("names both adapters", () => {
15
+ expect(signalsFor(Local)->Array.length)->toBeGreaterThan(0)
16
+ expect(signalsFor(AppSync)->Array.length)->toBeGreaterThan(0)
17
+ })
18
+
19
+ testSync("each adapter can express both refusals", () => {
20
+ [Local, AppSync]->Array.forEach(adapter => {
21
+ let kinds = signalsFor(adapter)->Array.map(s => s.kind)
22
+ expect(kinds->Array.includes(Entitlement))->toBe(true)
23
+ expect(kinds->Array.includes(Identity))->toBe(true)
24
+ })
25
+ })
26
+
27
+ testSync("within an adapter the two refusals are actually distinguishable", () => {
28
+ // A mapping whose two rows look identical to a client would be worthless —
29
+ // it has to differ in the status, the place to look, or the value found.
30
+ [Local, AppSync]->Array.forEach(adapter => {
31
+ let signals = signalsFor(adapter)
32
+ let entitlement = signals->Array.find(s => s.kind == Entitlement)
33
+ let identity = signals->Array.find(s => s.kind == Identity)
34
+ switch (entitlement, identity) {
35
+ | (Some(e), Some(i)) =>
36
+ let differs =
37
+ e.httpStatus != i.httpStatus || e.discriminator != i.discriminator || e.value != i.value
38
+ expect(differs)->toBe(true)
39
+ | _ => JsError.throwWithMessage("adapter is missing one of the two refusals")
40
+ }
41
+ })
42
+ })
43
+ })
44
+
45
+ describe("Auth_RefusalVocabulary.classify", () => {
46
+ testSync("classifies every row of the table as that row's kind", () => {
47
+ signals->Array.forEach(s => {
48
+ let errorType = s.discriminator->String.includes("errorType") ? Some(s.value) : None
49
+ let extensionsCode = s.discriminator == "extensions.code" ? Some(s.value) : None
50
+ // The 401 row's discriminator is a response header; status alone carries it.
51
+ expect(classify(~httpStatus=s.httpStatus, ~errorType?, ~extensionsCode?))->toEqual(Some(s.kind))
52
+ })
53
+ })
54
+
55
+ // The trap the table exists to defuse: on AppSync the entitlement value is a
56
+ // strict prefix of the identity one. A client matching by substring collapses
57
+ // them, and collapses them the dangerous way — signing out a caller whose
58
+ // session was fine.
59
+ testSync("Unauthorized and UnauthorizedException are opposite answers", () => {
60
+ expect(classify(~httpStatus=200, ~errorType="Unauthorized"))->toEqual(Some(Entitlement))
61
+ expect(classify(~httpStatus=401, ~errorType="UnauthorizedException"))->toEqual(Some(Identity))
62
+ })
63
+
64
+ testSync("a 401 is identity even when a field error would say otherwise", () => {
65
+ // Status is checked first precisely so this cannot go the other way.
66
+ expect(classify(~httpStatus=401, ~errorType="Unauthorized"))->toEqual(Some(Identity))
67
+ })
68
+
69
+ testSync("the local codes map to the same two kinds as the AppSync shapes", () => {
70
+ expect(classify(~httpStatus=200, ~extensionsCode="FORBIDDEN"))->toEqual(Some(Entitlement))
71
+ expect(classify(~httpStatus=200, ~extensionsCode="UNAUTHORIZED"))->toEqual(Some(Identity))
72
+ })
73
+
74
+ testSync("an unrecognised response is None, which is not 'allowed'", () => {
75
+ expect(classify(~httpStatus=200))->toEqual(None)
76
+ expect(classify(~httpStatus=200, ~errorType="ValidationError"))->toEqual(None)
77
+ expect(classify(~httpStatus=500))->toEqual(None)
78
+ })
79
+ })
80
+
81
+ describe("Auth_RefusalVocabulary.warrantsReauthentication", () => {
82
+ testSync("only an identity refusal asks the caller for new credentials", () => {
83
+ expect(warrantsReauthentication(Identity))->toBe(true)
84
+ expect(warrantsReauthentication(Entitlement))->toBe(false)
85
+ })
86
+
87
+ testSync("no adapter's entitlement refusal ends a session", () => {
88
+ // The regression this whole contract exists to prevent, stated once.
89
+ signals
90
+ ->Array.filter(s => s.kind == Entitlement)
91
+ ->Array.forEach(s => expect(warrantsReauthentication(s.kind))->toBe(false))
92
+ })
93
+ })
@@ -0,0 +1,79 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
4
+ import * as Auth_RefusalVocabulary$ReventlessCore from "../../src/adapter/Auth/Auth_RefusalVocabulary.res.mjs";
5
+
6
+ globalThis.describe("Auth_RefusalVocabulary — the table", () => {
7
+ globalThis.test("names both adapters", () => {
8
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.signalsFor("Local").length).toBeGreaterThan(0);
9
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.signalsFor("AppSync").length).toBeGreaterThan(0);
10
+ });
11
+ globalThis.test("each adapter can express both refusals", () => {
12
+ [
13
+ "Local",
14
+ "AppSync"
15
+ ].forEach(adapter => {
16
+ let kinds = Auth_RefusalVocabulary$ReventlessCore.signalsFor(adapter).map(s => s.kind);
17
+ globalThis.expect(kinds.includes("Entitlement")).toBe(true);
18
+ globalThis.expect(kinds.includes("Identity")).toBe(true);
19
+ });
20
+ });
21
+ globalThis.test("within an adapter the two refusals are actually distinguishable", () => {
22
+ [
23
+ "Local",
24
+ "AppSync"
25
+ ].forEach(adapter => {
26
+ let signals = Auth_RefusalVocabulary$ReventlessCore.signalsFor(adapter);
27
+ let entitlement = signals.find(s => s.kind === "Entitlement");
28
+ let identity = signals.find(s => s.kind === "Identity");
29
+ if (entitlement === undefined) {
30
+ return Stdlib_JsError.throwWithMessage("adapter is missing one of the two refusals");
31
+ }
32
+ if (identity === undefined) {
33
+ return Stdlib_JsError.throwWithMessage("adapter is missing one of the two refusals");
34
+ }
35
+ let differs = entitlement.httpStatus !== identity.httpStatus || entitlement.discriminator !== identity.discriminator || entitlement.value !== identity.value;
36
+ globalThis.expect(differs).toBe(true);
37
+ });
38
+ });
39
+ });
40
+
41
+ globalThis.describe("Auth_RefusalVocabulary.classify", () => {
42
+ globalThis.test("classifies every row of the table as that row's kind", () => {
43
+ Auth_RefusalVocabulary$ReventlessCore.signals.forEach(s => {
44
+ let errorType = s.discriminator.includes("errorType") ? s.value : undefined;
45
+ let extensionsCode = s.discriminator === "extensions.code" ? s.value : undefined;
46
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(s.httpStatus, errorType, extensionsCode)).toEqual(s.kind);
47
+ });
48
+ });
49
+ globalThis.test("Unauthorized and UnauthorizedException are opposite answers", () => {
50
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(200, "Unauthorized", undefined)).toEqual("Entitlement");
51
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(401, "UnauthorizedException", undefined)).toEqual("Identity");
52
+ });
53
+ globalThis.test("a 401 is identity even when a field error would say otherwise", () => {
54
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(401, "Unauthorized", undefined)).toEqual("Identity");
55
+ });
56
+ globalThis.test("the local codes map to the same two kinds as the AppSync shapes", () => {
57
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(200, undefined, "FORBIDDEN")).toEqual("Entitlement");
58
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(200, undefined, "UNAUTHORIZED")).toEqual("Identity");
59
+ });
60
+ globalThis.test("an unrecognised response is None, which is not 'allowed'", () => {
61
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(200, undefined, undefined)).toEqual(undefined);
62
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(200, "ValidationError", undefined)).toEqual(undefined);
63
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.classify(500, undefined, undefined)).toEqual(undefined);
64
+ });
65
+ });
66
+
67
+ globalThis.describe("Auth_RefusalVocabulary.warrantsReauthentication", () => {
68
+ globalThis.test("only an identity refusal asks the caller for new credentials", () => {
69
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.warrantsReauthentication("Identity")).toBe(true);
70
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.warrantsReauthentication("Entitlement")).toBe(false);
71
+ });
72
+ globalThis.test("no adapter's entitlement refusal ends a session", () => {
73
+ Auth_RefusalVocabulary$ReventlessCore.signals.filter(s => s.kind === "Entitlement").forEach(s => {
74
+ globalThis.expect(Auth_RefusalVocabulary$ReventlessCore.warrantsReauthentication(s.kind)).toBe(false);
75
+ });
76
+ });
77
+ });
78
+
79
+ /* Not a pure module */