@reventlessdev/reventless-local 3.0.0-alpha.220 → 3.0.0-alpha.222
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 +15 -0
- package/package.json +11 -11
- package/src/LocalPlatformRegistry.res +189 -0
- package/src/LocalPlatformRegistry.res.mjs +162 -0
- package/src/LocalSeedTarget.res +177 -0
- package/src/LocalSeedTarget.res.mjs +172 -0
- package/src/Platform.res +14 -1
- package/src/Platform.res.mjs +11 -0
- package/src/adapter/Auth/Auth_GraphqlContext.res +82 -11
- package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +40 -2
- package/src/adapter/BackendState.res +15 -0
- package/src/adapter/BackendState.res.mjs +29 -0
- package/src/adapter/PlatformGraphQL_Server.res +9 -4
- package/src/reset/LocalSeedReset.res +38 -16
- package/src/reset/LocalSeedReset.res.mjs +49 -21
- package/tests/LocalPlatformRegistryTest.res +143 -0
- package/tests/LocalPlatformRegistryTest.res.mjs +152 -0
- package/tests/adapter/RequireGroupRefusalTest.res +107 -0
- package/tests/adapter/RequireGroupRefusalTest.res.mjs +130 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Nodepath from "node:path";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as Seed_Prompt$ReventlessSeed from "@reventlessdev/reventless-seed/src/Seed_Prompt.res.mjs";
|
|
6
|
+
import * as Seed_Connect$ReventlessSeed from "@reventlessdev/reventless-seed/src/Seed_Connect.res.mjs";
|
|
7
|
+
import * as LocalPlatformRegistry$ReventlessLocal from "./LocalPlatformRegistry.res.mjs";
|
|
8
|
+
|
|
9
|
+
let defaultEndpoint = "http://localhost:4000/graphql";
|
|
10
|
+
|
|
11
|
+
function loginFor(endpoint) {
|
|
12
|
+
let i = endpoint.indexOf("://");
|
|
13
|
+
if (i === -1) {
|
|
14
|
+
return endpoint;
|
|
15
|
+
}
|
|
16
|
+
let afterScheme = i + 3 | 0;
|
|
17
|
+
let slash = endpoint.indexOf("/", afterScheme);
|
|
18
|
+
return (
|
|
19
|
+
slash !== -1 ? endpoint.slice(0, slash) : endpoint
|
|
20
|
+
) + "/__inmemory/login";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function relativeIfInside(path) {
|
|
24
|
+
let cwd = process.cwd();
|
|
25
|
+
if (path.startsWith(cwd + Nodepath.sep)) {
|
|
26
|
+
return path.slice((cwd + Nodepath.sep).length);
|
|
27
|
+
} else {
|
|
28
|
+
return path;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function storeLabel(store) {
|
|
33
|
+
let match = store.kind;
|
|
34
|
+
let match$1 = store.path;
|
|
35
|
+
if (match === "sqlite" && match$1 !== undefined) {
|
|
36
|
+
return `sqlite ` + relativeIfInside(match$1);
|
|
37
|
+
} else {
|
|
38
|
+
return match;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function shortAppName(app) {
|
|
43
|
+
let i = app.indexOf("/");
|
|
44
|
+
if (i !== -1) {
|
|
45
|
+
return app.slice(i + 1 | 0);
|
|
46
|
+
} else {
|
|
47
|
+
return app;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function entryLabel(e) {
|
|
52
|
+
return `:` + e.port.toString() + ` ` + shortAppName(e.app) + ` ` + storeLabel(e.store);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function select() {
|
|
56
|
+
let endpoint = Seed_Prompt$ReventlessSeed.envValue("REVENTLESS_GRAPHQL_ENDPOINT");
|
|
57
|
+
if (endpoint !== undefined) {
|
|
58
|
+
return {
|
|
59
|
+
endpoint: endpoint,
|
|
60
|
+
loginEndpoint: Stdlib_Option.getOr(Seed_Prompt$ReventlessSeed.envValue("REVENTLESS_LOGIN_ENDPOINT"), loginFor(endpoint)),
|
|
61
|
+
origin: "EnvOverride"
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
let running = LocalPlatformRegistry$ReventlessLocal.list(undefined, undefined);
|
|
65
|
+
let preselected = Stdlib_Option.flatMap(Seed_Prompt$ReventlessSeed.envValue("SEED_PLATFORM"), v => running.find(e => e.port.toString() === v));
|
|
66
|
+
let chosen = preselected !== undefined ? preselected : (
|
|
67
|
+
running.length !== 0 ? await Seed_Prompt$ReventlessSeed.select("Platform:", running.map(e => [
|
|
68
|
+
entryLabel(e),
|
|
69
|
+
e
|
|
70
|
+
]), "SEED_PLATFORM") : undefined
|
|
71
|
+
);
|
|
72
|
+
if (chosen !== undefined) {
|
|
73
|
+
return {
|
|
74
|
+
endpoint: chosen.endpoint,
|
|
75
|
+
loginEndpoint: chosen.loginEndpoint,
|
|
76
|
+
origin: {
|
|
77
|
+
TAG: "Running",
|
|
78
|
+
_0: chosen
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
} else {
|
|
82
|
+
return {
|
|
83
|
+
endpoint: defaultEndpoint,
|
|
84
|
+
loginEndpoint: loginFor(defaultEndpoint),
|
|
85
|
+
origin: "NoneRunning"
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function announce(t) {
|
|
91
|
+
let entry = t.origin;
|
|
92
|
+
if (typeof entry !== "object") {
|
|
93
|
+
if (entry === "EnvOverride") {
|
|
94
|
+
console.log(`→ ` + t.endpoint + ` (REVENTLESS_GRAPHQL_ENDPOINT)`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
console.log(`→ ` + t.endpoint + ` (no local platform registered here — trying the default)`);
|
|
98
|
+
return;
|
|
99
|
+
} else {
|
|
100
|
+
let entry$1 = entry._0;
|
|
101
|
+
console.log(`→ ` + entry$1.endpoint + ` · ` + storeLabel(entry$1.store) + ` (` + shortAppName(entry$1.app) + `)`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function storePath(t) {
|
|
107
|
+
let match = t.origin;
|
|
108
|
+
if (typeof match !== "object") {
|
|
109
|
+
if (match === "EnvOverride") {
|
|
110
|
+
return {
|
|
111
|
+
TAG: "Error",
|
|
112
|
+
_0: `REVENTLESS_GRAPHQL_ENDPOINT names ` + t.endpoint + `, which does not say which store that platform opened. Set REVENTLESS_LOCAL_BACKEND to the store file, or unset REVENTLESS_GRAPHQL_ENDPOINT to pick a running platform.`
|
|
113
|
+
};
|
|
114
|
+
} else {
|
|
115
|
+
return {
|
|
116
|
+
TAG: "Error",
|
|
117
|
+
_0: "no local platform is running here. Start one, or set REVENTLESS_LOCAL_BACKEND to reset a store directly."
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
let match$1 = match._0;
|
|
122
|
+
let match$2 = match$1.store;
|
|
123
|
+
let kind = match$2.kind;
|
|
124
|
+
let endpoint = match$1.endpoint;
|
|
125
|
+
switch (kind) {
|
|
126
|
+
case "memory" :
|
|
127
|
+
return {
|
|
128
|
+
TAG: "Error",
|
|
129
|
+
_0: `the platform at ` + endpoint + ` keeps its store in memory; restart it to empty it.`
|
|
130
|
+
};
|
|
131
|
+
case "postgres" :
|
|
132
|
+
return {
|
|
133
|
+
TAG: "Error",
|
|
134
|
+
_0: `the platform at ` + endpoint + ` is backed by Postgres, which keeps its event logs off this machine. Reset it against the database.`
|
|
135
|
+
};
|
|
136
|
+
case "sqlite" :
|
|
137
|
+
let path = match$2.path;
|
|
138
|
+
if (path !== undefined) {
|
|
139
|
+
return {
|
|
140
|
+
TAG: "Ok",
|
|
141
|
+
_0: path
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
TAG: "Error",
|
|
148
|
+
_0: `the platform at ` + endpoint + ` reports an unknown store kind "` + kind + `".`
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function connect() {
|
|
153
|
+
return async () => {
|
|
154
|
+
let target = await select();
|
|
155
|
+
announce(target);
|
|
156
|
+
return await Seed_Connect$ReventlessSeed.local(target.endpoint, target.loginEndpoint, undefined)();
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export {
|
|
161
|
+
defaultEndpoint,
|
|
162
|
+
loginFor,
|
|
163
|
+
relativeIfInside,
|
|
164
|
+
storeLabel,
|
|
165
|
+
shortAppName,
|
|
166
|
+
entryLabel,
|
|
167
|
+
select,
|
|
168
|
+
announce,
|
|
169
|
+
storePath,
|
|
170
|
+
connect,
|
|
171
|
+
}
|
|
172
|
+
/* node:path Not a pure module */
|
package/src/Platform.res
CHANGED
|
@@ -1303,6 +1303,19 @@ module MakeWithConfig = (
|
|
|
1303
1303
|
PlatformMCP_Server.printDiagnostics()
|
|
1304
1304
|
}
|
|
1305
1305
|
}
|
|
1306
|
+
// Announce this platform — endpoint AND the store it opened — so `seed` and
|
|
1307
|
+
// `seed:reset` can address it instead of inferring a store from their own
|
|
1308
|
+
// environment. Here rather than in makePlatform because this is the one
|
|
1309
|
+
// point both API modes pass through once the servers are actually up: in
|
|
1310
|
+
// split mode they start just above, in unified mode makePlatform started
|
|
1311
|
+
// them and this still runs before the process serves its first request.
|
|
1312
|
+
let (kind, path) = BackendState.describeStore()
|
|
1313
|
+
LocalPlatformRegistry.register(
|
|
1314
|
+
~port=domainPort,
|
|
1315
|
+
~endpoint=`http://localhost:${domainPort->Int.toString}/graphql`,
|
|
1316
|
+
~loginEndpoint=`http://localhost:${domainPort->Int.toString}/__inmemory/login`,
|
|
1317
|
+
~store={kind, path},
|
|
1318
|
+
)
|
|
1306
1319
|
// Fire onPlatformDeployed after all servers are started so late-deployed
|
|
1307
1320
|
// plugins (e.g. PlatformInspector) have their handler refs populated.
|
|
1308
1321
|
ReventlessCore.Plugin_Helpers.firePlatformDeployedHook({
|
|
@@ -2238,7 +2251,7 @@ module MakeWithConfig = (
|
|
|
2238
2251
|
// extracts identity from the bearer token, same as the Domain server.
|
|
2239
2252
|
// Without this, Platform_* queries / mutations run as `anonymous` and
|
|
2240
2253
|
// skip the group authorization that AppSync would enforce via
|
|
2241
|
-
// `@
|
|
2254
|
+
// `@aws_cognito_user_pools(cognito_groups: ["Admin"])` in production. The Domain
|
|
2242
2255
|
// server's `asInterface.start` accepts but ignores `~contextFactory`
|
|
2243
2256
|
// because it always wires its own internal auth context.
|
|
2244
2257
|
adminGraphQL.start(
|
package/src/Platform.res.mjs
CHANGED
|
@@ -69,6 +69,7 @@ import * as LocalUploadResolvers$ReventlessLocal from "./adapter/LocalUploadReso
|
|
|
69
69
|
import * as ProjectionCheckpoint$ReventlessLocal from "./adapter/ProjectionCheckpoint.res.mjs";
|
|
70
70
|
import * as ExtensionPointMapping$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/ExtensionPointMapping.res.mjs";
|
|
71
71
|
import * as LocalGeocodeResolvers$ReventlessLocal from "./adapter/LocalGeocodeResolvers.res.mjs";
|
|
72
|
+
import * as LocalPlatformRegistry$ReventlessLocal from "./LocalPlatformRegistry.res.mjs";
|
|
72
73
|
import * as UiFragments_Projection$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateViewSlice/UiFragments_Projection.res.mjs";
|
|
73
74
|
import * as EventLogStorage_Sqlite$ReventlessLocal from "./adapter/EventLog/EventLogStorage_Sqlite.res.mjs";
|
|
74
75
|
import * as ExtensionPoint_Builder$ReventlessLocal from "./components/ExtensionPoint_Builder.res.mjs";
|
|
@@ -1105,6 +1106,11 @@ function MakeWithConfig(Config) {
|
|
|
1105
1106
|
PlatformMCP_Server$ReventlessLocal.printDiagnostics();
|
|
1106
1107
|
}
|
|
1107
1108
|
}
|
|
1109
|
+
let match = BackendState$ReventlessLocal.describeStore();
|
|
1110
|
+
LocalPlatformRegistry$ReventlessLocal.register(domainPort, `http://localhost:` + domainPort.toString() + `/graphql`, `http://localhost:` + domainPort.toString() + `/__inmemory/login`, {
|
|
1111
|
+
kind: match[0],
|
|
1112
|
+
path: match[1]
|
|
1113
|
+
});
|
|
1108
1114
|
Plugin_Helpers$ReventlessCore.firePlatformDeployedHook({
|
|
1109
1115
|
name: "local",
|
|
1110
1116
|
environment: Pulumi.getStack(),
|
|
@@ -2826,6 +2832,11 @@ function Make($star) {
|
|
|
2826
2832
|
PlatformGraphQL_Server$ReventlessLocal.printDiagnostics();
|
|
2827
2833
|
PlatformMCP_Server$ReventlessLocal.printDiagnostics();
|
|
2828
2834
|
}
|
|
2835
|
+
let match = BackendState$ReventlessLocal.describeStore();
|
|
2836
|
+
LocalPlatformRegistry$ReventlessLocal.register(domainPort, `http://localhost:` + domainPort.toString() + `/graphql`, `http://localhost:` + domainPort.toString() + `/__inmemory/login`, {
|
|
2837
|
+
kind: match[0],
|
|
2838
|
+
path: match[1]
|
|
2839
|
+
});
|
|
2829
2840
|
Plugin_Helpers$ReventlessCore.firePlatformDeployedHook({
|
|
2830
2841
|
name: "local",
|
|
2831
2842
|
environment: Pulumi.getStack(),
|
|
@@ -6,9 +6,15 @@
|
|
|
6
6
|
// - Missing header → `defaultUser`
|
|
7
7
|
// - Invalid bearer is rejected at HTTP level before this runs (see
|
|
8
8
|
// `DomainGraphQL_Server._dispatch`); on the admin server there is no
|
|
9
|
-
// dispatch layer, so an invalid bearer
|
|
10
|
-
//
|
|
11
|
-
// directive at the schema layer in production.
|
|
9
|
+
// dispatch layer, so an invalid bearer reaches this as an `AuthError`.
|
|
10
|
+
// AppSync enforces the same `@aws_cognito_user_pools(cognito_groups:
|
|
11
|
+
// ["Admin"])` directive at the schema layer in production.
|
|
12
|
+
//
|
|
13
|
+
// The outcome is carried onto the context beside the identity, not folded into
|
|
14
|
+
// it. Whether credentials verified and whether the verified caller holds a group
|
|
15
|
+
// are different questions with different answers for the client — one is worth
|
|
16
|
+
// retrying with new credentials, the other never is — and a group check that
|
|
17
|
+
// sees only the identity cannot tell which one it is refusing.
|
|
12
18
|
|
|
13
19
|
module YG = GraphqlYoga
|
|
14
20
|
|
|
@@ -32,13 +38,34 @@ let identityFromAuthResult = (result: Reventless.Identity.authResult): Reventles
|
|
|
32
38
|
| AuthError(_) => Reventless.Identity.anonymous
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/**
|
|
42
|
+
Whether the request presented credentials the adapter accepted.
|
|
43
|
+
|
|
44
|
+
`identityFromAuthResult` cannot answer this: it maps both `Anonymous` and
|
|
45
|
+
`AuthError` onto the same anonymous identity, so by the time a resolver holds
|
|
46
|
+
an identity, "nobody presented credentials", "the credentials did not verify"
|
|
47
|
+
and "credentials verified for someone without the group" are one shape. That
|
|
48
|
+
collapse is what made a group check unable to say which of them it was
|
|
49
|
+
refusing.
|
|
50
|
+
|
|
51
|
+
A request carrying no `Authorization` header at all counts as authenticated
|
|
52
|
+
here, because `LocalAuth.authenticate` answers it with `defaultUser` by
|
|
53
|
+
design — in-memory mode decides such a caller is somebody, and this reports
|
|
54
|
+
the decision rather than second-guessing it.
|
|
55
|
+
*/
|
|
56
|
+
let isAuthenticated = (result: Reventless.Identity.authResult): bool =>
|
|
57
|
+
switch result {
|
|
58
|
+
| Authenticated(_) => true
|
|
59
|
+
| Anonymous | AuthError(_) => false
|
|
60
|
+
}
|
|
61
|
+
|
|
35
62
|
let buildAuthContext = async (initial: YG.initialContext): JSON.t => {
|
|
36
63
|
let ctx: yogaInitialCtx = Obj.magic(initial)
|
|
37
64
|
let headers = extractHeaders(ctx.request.headers)
|
|
38
65
|
let requestContext: ReventlessCore.Auth_Adapter.requestContext = {headers: headers}
|
|
39
66
|
let result = await LocalAuth.authenticate(requestContext)
|
|
40
67
|
let identity = identityFromAuthResult(result)
|
|
41
|
-
Obj.magic({"identity": identity})
|
|
68
|
+
Obj.magic({"identity": identity, "authenticated": isAuthenticated(result)})
|
|
42
69
|
}
|
|
43
70
|
|
|
44
71
|
// Read the identity attached by `buildAuthContext` from the resolver context.
|
|
@@ -54,27 +81,71 @@ let extractIdentity = (ctx: JSON.t): Reventless.Identity.t =>
|
|
|
54
81
|
| _ => Reventless.Identity.anonymous
|
|
55
82
|
}
|
|
56
83
|
|
|
84
|
+
// Read the authentication outcome `buildAuthContext` recorded. Falls back to
|
|
85
|
+
// `false` for the same reasons `extractIdentity` falls back to anonymous, and
|
|
86
|
+
// with the same conservatism: a context this cannot read is one that proves
|
|
87
|
+
// nothing about the caller, and "unauthenticated" is the answer that asks them
|
|
88
|
+
// to present credentials rather than telling them theirs were rejected.
|
|
89
|
+
let extractAuthenticated = (ctx: JSON.t): bool =>
|
|
90
|
+
try {
|
|
91
|
+
switch (ctx->Obj.magic)["authenticated"]->Nullable.toOption {
|
|
92
|
+
| Some(authenticated) => (authenticated: bool)
|
|
93
|
+
| None => false
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
| _ => false
|
|
97
|
+
}
|
|
98
|
+
|
|
57
99
|
// An unauthorized caller reads the reason: `GraphQL_CallerError` explains why a
|
|
58
|
-
// resolver has to construct the error rather than throw a bare one.
|
|
59
|
-
//
|
|
100
|
+
// resolver has to construct the error rather than throw a bare one. The AWS
|
|
101
|
+
// counterpart is the field-level refusal AppSync returns for a caller who fails
|
|
102
|
+
// an `@aws_cognito_user_pools(cognito_groups: [...])` gate.
|
|
60
103
|
let makeGraphqlError = GraphQL_CallerError.make
|
|
61
104
|
|
|
105
|
+
/**
|
|
106
|
+
Nobody the server could identify asked for a gated field.
|
|
107
|
+
|
|
108
|
+
Keeps the code it has always carried. A client reading only `UNAUTHORIZED` is
|
|
109
|
+
one that treats it as "your credentials are not being honoured", and narrowing
|
|
110
|
+
the code to the case where that is true makes such a client correct rather than
|
|
111
|
+
breaking it.
|
|
112
|
+
*/
|
|
62
113
|
let unauthorizedError = (~group: string): exn =>
|
|
63
114
|
makeGraphqlError(
|
|
64
115
|
`Unauthorized: requires group "${group}"`,
|
|
65
|
-
{"extensions": {"code":
|
|
116
|
+
{"extensions": {"code": ReventlessCore.Auth_RefusalVocabulary.localIdentityCode}},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
Somebody the server identified asked for a field their groups do not cover.
|
|
121
|
+
|
|
122
|
+
Separate from `unauthorizedError` because the two ask for different things and
|
|
123
|
+
a client cannot tell them apart from the refusal alone. Answering both with one
|
|
124
|
+
code left every caller to guess, and the guess that fits an expired token —
|
|
125
|
+
discard the session and ask them to sign in again — ends a working session for
|
|
126
|
+
a caller who was simply not entitled to the field. Presenting credentials again
|
|
127
|
+
cannot change this answer, which is what the distinct code says.
|
|
128
|
+
*/
|
|
129
|
+
let forbiddenError = (~group: string): exn =>
|
|
130
|
+
makeGraphqlError(
|
|
131
|
+
`Forbidden: requires group "${group}"`,
|
|
132
|
+
{"extensions": {"code": ReventlessCore.Auth_RefusalVocabulary.localEntitlementCode}},
|
|
66
133
|
)
|
|
67
134
|
|
|
68
|
-
// Wrap a resolver so it
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
135
|
+
// Wrap a resolver so it refuses a caller whose identity lacks the required
|
|
136
|
+
// group. Mirrors AppSync's `@aws_cognito_user_pools(cognito_groups: [...])`
|
|
137
|
+
// semantics for admin fields on the in-memory adapter. Both paths draw the same
|
|
138
|
+
// two-way distinction — AppSync as HTTP 401 versus a field error, this as two
|
|
139
|
+
// `extensions.code` values — and `ReventlessCore.Auth_RefusalVocabulary` is the
|
|
140
|
+
// mapping between them. Use for fields whose corresponding read-model entry
|
|
72
141
|
// carries `authorization: Some({group, ...})` — pass the group string here.
|
|
73
142
|
let requireGroup = (~group: string, resolver: YG.resolverFn): YG.resolverFn =>
|
|
74
143
|
async (root, args, ctx) => {
|
|
75
144
|
let identity = extractIdentity(ctx)
|
|
76
145
|
if identity.groups->Array.includes(group) {
|
|
77
146
|
await resolver(root, args, ctx)
|
|
147
|
+
} else if extractAuthenticated(ctx) {
|
|
148
|
+
throw(forbiddenError(~group))
|
|
78
149
|
} else {
|
|
79
150
|
throw(unauthorizedError(~group))
|
|
80
151
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as Graphql from "graphql";
|
|
4
4
|
import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
|
|
5
5
|
import * as LocalAuth$ReventlessLocal from "./LocalAuth.res.mjs";
|
|
6
|
+
import * as Auth_RefusalVocabulary$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_RefusalVocabulary.res.mjs";
|
|
6
7
|
|
|
7
8
|
function extractHeaders(headers) {
|
|
8
9
|
let acc = {};
|
|
@@ -20,14 +21,24 @@ function identityFromAuthResult(result) {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
function isAuthenticated(result) {
|
|
25
|
+
if (typeof result !== "object") {
|
|
26
|
+
return false;
|
|
27
|
+
} else {
|
|
28
|
+
return result.TAG === "Authenticated";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
async function buildAuthContext(initial) {
|
|
24
33
|
let headers = extractHeaders(initial.request.headers);
|
|
25
34
|
let requestContext = {
|
|
26
35
|
headers: headers
|
|
27
36
|
};
|
|
28
37
|
let result = await LocalAuth$ReventlessLocal.authenticate(requestContext);
|
|
38
|
+
let identity = identityFromAuthResult(result);
|
|
29
39
|
return {
|
|
30
|
-
identity:
|
|
40
|
+
identity: identity,
|
|
41
|
+
authenticated: isAuthenticated(result)
|
|
31
42
|
};
|
|
32
43
|
}
|
|
33
44
|
|
|
@@ -44,6 +55,19 @@ function extractIdentity(ctx) {
|
|
|
44
55
|
}
|
|
45
56
|
}
|
|
46
57
|
|
|
58
|
+
function extractAuthenticated(ctx) {
|
|
59
|
+
try {
|
|
60
|
+
let authenticated = ctx.authenticated;
|
|
61
|
+
if (authenticated == null) {
|
|
62
|
+
return false;
|
|
63
|
+
} else {
|
|
64
|
+
return authenticated;
|
|
65
|
+
}
|
|
66
|
+
} catch (exn) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
47
71
|
function makeGraphqlError(prim0, prim1) {
|
|
48
72
|
return new Graphql.GraphQLError(prim0, prim1);
|
|
49
73
|
}
|
|
@@ -51,7 +75,15 @@ function makeGraphqlError(prim0, prim1) {
|
|
|
51
75
|
function unauthorizedError(group) {
|
|
52
76
|
return new Graphql.GraphQLError(`Unauthorized: requires group "` + group + `"`, {
|
|
53
77
|
extensions: {
|
|
54
|
-
code:
|
|
78
|
+
code: Auth_RefusalVocabulary$ReventlessCore.localIdentityCode
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function forbiddenError(group) {
|
|
84
|
+
return new Graphql.GraphQLError(`Forbidden: requires group "` + group + `"`, {
|
|
85
|
+
extensions: {
|
|
86
|
+
code: Auth_RefusalVocabulary$ReventlessCore.localEntitlementCode
|
|
55
87
|
}
|
|
56
88
|
});
|
|
57
89
|
}
|
|
@@ -62,6 +94,9 @@ function requireGroup(group, resolver) {
|
|
|
62
94
|
if (identity.groups.includes(group)) {
|
|
63
95
|
return await resolver(root, args, ctx);
|
|
64
96
|
}
|
|
97
|
+
if (extractAuthenticated(ctx)) {
|
|
98
|
+
throw forbiddenError(group);
|
|
99
|
+
}
|
|
65
100
|
throw unauthorizedError(group);
|
|
66
101
|
};
|
|
67
102
|
}
|
|
@@ -72,10 +107,13 @@ export {
|
|
|
72
107
|
YG,
|
|
73
108
|
extractHeaders,
|
|
74
109
|
identityFromAuthResult,
|
|
110
|
+
isAuthenticated,
|
|
75
111
|
buildAuthContext,
|
|
76
112
|
extractIdentity,
|
|
113
|
+
extractAuthenticated,
|
|
77
114
|
makeGraphqlError,
|
|
78
115
|
unauthorizedError,
|
|
116
|
+
forbiddenError,
|
|
79
117
|
requireGroup,
|
|
80
118
|
}
|
|
81
119
|
/* graphql Not a pure module */
|
|
@@ -50,3 +50,18 @@ let getObjectStoreRoot = () =>
|
|
|
50
50
|
| Sqlite({path}) if path != ":memory:" => Some(NodePath.dirname(path))
|
|
51
51
|
| Sqlite(_) | Memory | Postgres(_) => None
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
// How the active backend names itself to a tool outside this process — the
|
|
55
|
+
// answer `LocalPlatformRegistry` publishes so `seed` and `seed:reset` stop
|
|
56
|
+
// inferring it from their own environment.
|
|
57
|
+
//
|
|
58
|
+
// Absolute, because the platform resolved `./.reventless/local.db` against ITS
|
|
59
|
+
// cwd and a reader may not share it. `:memory:` reports "memory": there is no
|
|
60
|
+
// file to open, which is the only distinction that changes what a tool can do.
|
|
61
|
+
let describeStore = (): (string, option<string>) =>
|
|
62
|
+
switch current.contents {
|
|
63
|
+
| Memory => ("memory", None)
|
|
64
|
+
| Sqlite({path}) if path == ":memory:" => ("memory", None)
|
|
65
|
+
| Sqlite({path}) => ("sqlite", Some(NodePath.resolve([path])))
|
|
66
|
+
| Postgres(_) => ("postgres", None)
|
|
67
|
+
}
|
|
@@ -58,6 +58,34 @@ function getObjectStoreRoot() {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function describeStore() {
|
|
62
|
+
let match = current.contents;
|
|
63
|
+
if (typeof match !== "object") {
|
|
64
|
+
return [
|
|
65
|
+
"memory",
|
|
66
|
+
undefined
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
if (match.TAG !== "Sqlite") {
|
|
70
|
+
return [
|
|
71
|
+
"postgres",
|
|
72
|
+
undefined
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
let path = match.path;
|
|
76
|
+
if (path === ":memory:") {
|
|
77
|
+
return [
|
|
78
|
+
"memory",
|
|
79
|
+
undefined
|
|
80
|
+
];
|
|
81
|
+
} else {
|
|
82
|
+
return [
|
|
83
|
+
"sqlite",
|
|
84
|
+
Nodepath.resolve(path)
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
61
89
|
export {
|
|
62
90
|
current,
|
|
63
91
|
setMemory,
|
|
@@ -66,5 +94,6 @@ export {
|
|
|
66
94
|
getSqliteDb,
|
|
67
95
|
getPostgresPool,
|
|
68
96
|
getObjectStoreRoot,
|
|
97
|
+
describeStore,
|
|
69
98
|
}
|
|
70
99
|
/* node:path Not a pure module */
|
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Authorization: every query AND mutation resolver registered here is
|
|
12
12
|
// wrapped with `Auth_GraphqlContext.requireGroup(~group="Admin")` so
|
|
13
|
-
// non-Admin identities
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
13
|
+
// non-Admin identities are refused before the underlying resolver runs.
|
|
14
|
+
// Mirrors AppSync's `@aws_cognito_user_pools(cognito_groups: ["Admin"])`
|
|
15
|
+
// directive that gates Platform_* fields in the AWS adapter.
|
|
16
|
+
//
|
|
17
|
+
// The refusal names which kind it is: `FORBIDDEN` for a caller the server
|
|
18
|
+
// identified who does not hold the group, `UNAUTHORIZED` for one it could not
|
|
19
|
+
// identify at all. Every surface here is admin-gated, so a client discovering
|
|
20
|
+
// through them meets this refusal as a matter of course rather than as a
|
|
21
|
+
// failure, and cannot afford to read it as a session that has ended.
|
|
17
22
|
//
|
|
18
23
|
// Mutations are wrapped (in addition to their per-command
|
|
19
24
|
// `commandAuthorization` rule inside `CommandGeneratorResolvers_GraphQL.register`)
|
|
@@ -476,30 +476,52 @@ let scopeOptions = (plugins: array<string>): array<(string, scope)> =>
|
|
|
476
476
|
[("platform", Platform), ("everything", Everything)],
|
|
477
477
|
)
|
|
478
478
|
|
|
479
|
-
/** Runs the reset against the store the
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
479
|
+
/** Runs the reset against the store the selected platform actually opened.
|
|
480
|
+
|
|
481
|
+
Resolution order, and why it is this way round: the store used to be read off
|
|
482
|
+
`REVENTLESS_LOCAL_BACKEND` in THIS process, which describes no platform at
|
|
483
|
+
all. With a second platform up — the VS Code runner beside a hand-started one
|
|
484
|
+
— that emptied a database nobody was serving while reporting success, and the
|
|
485
|
+
`seed` that follows then refused because the served store was still full. So
|
|
486
|
+
the running platform is asked instead, and the variable is kept only for the
|
|
487
|
+
case discovery cannot serve: a store whose platform is down.
|
|
488
|
+
|
|
489
|
+
1. `~dbPath` — a programmatic caller has already decided.
|
|
490
|
+
2. `REVENTLESS_LOCAL_BACKEND` **set in this shell** — an explicit target.
|
|
491
|
+
3. the platform selected by {!LocalSeedTarget.select} — the normal path.
|
|
492
|
+
|
|
493
|
+
Note for callers wiring this into a package script: do NOT default the
|
|
494
|
+
variable there (`${REVENTLESS_LOCAL_BACKEND:-sqlite:./…}`). It would make
|
|
495
|
+
step 2 always fire, and that default is the very guess this replaces. */
|
|
485
496
|
let run = (~dbPath: option<string>=?): unit => {
|
|
486
497
|
let go = async () => {
|
|
487
|
-
let
|
|
488
|
-
| Some(p) => Some(p)
|
|
489
|
-
| None =>
|
|
498
|
+
let fromBackendEnv = () =>
|
|
490
499
|
switch Backend.fromEnv() {
|
|
491
|
-
| Backend.Sqlite({path}) if path != ":memory:" =>
|
|
500
|
+
| Backend.Sqlite({path}) if path != ":memory:" => Ok(path)
|
|
492
501
|
| Backend.Sqlite(_) | Backend.Memory =>
|
|
493
|
-
|
|
494
|
-
"
|
|
502
|
+
Error(
|
|
503
|
+
"REVENTLESS_LOCAL_BACKEND selects an in-memory store, which a restart already empties.",
|
|
495
504
|
)
|
|
496
|
-
None
|
|
497
505
|
| Backend.Postgres(_) =>
|
|
498
|
-
|
|
499
|
-
"
|
|
506
|
+
Error(
|
|
507
|
+
"the Postgres backend keeps its event logs off this machine. Reset it against the database.",
|
|
500
508
|
)
|
|
501
|
-
None
|
|
502
509
|
}
|
|
510
|
+
|
|
511
|
+
let resolved = switch (dbPath, Seed.Prompt.envValue("REVENTLESS_LOCAL_BACKEND")) {
|
|
512
|
+
| (Some(p), _) => Ok(p)
|
|
513
|
+
| (None, Some(_)) => fromBackendEnv()
|
|
514
|
+
| (None, None) =>
|
|
515
|
+
let target = await LocalSeedTarget.select()
|
|
516
|
+
target->LocalSeedTarget.announce
|
|
517
|
+
target->LocalSeedTarget.storePath
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
let resolved = switch resolved {
|
|
521
|
+
| Ok(path) => Some(path)
|
|
522
|
+
| Error(reason) =>
|
|
523
|
+
Console.log(`Nothing to reset — ${reason}`)
|
|
524
|
+
None
|
|
503
525
|
}
|
|
504
526
|
|
|
505
527
|
switch resolved {
|