[CORL-628] CLI Support (#2646)

* feat: improved api for reloading tenants for cli

* fix: cleaned up merge beheviour

* feat: added support for stream/story edge

* feat: support tenant install

* fix: updated snapshot

* fix: fixed tests
This commit is contained in:
Wyatt Johnson
2019-11-08 00:40:56 +00:00
committed by GitHub
parent 2dbba52fbd
commit ce4a3408fc
74 changed files with 690 additions and 215 deletions
@@ -26,7 +26,6 @@ export function setAccessTokenInLocalState(
localRecord.setValue(accessToken || "", "accessToken");
if (accessToken) {
const { payload } = parseJWT(accessToken);
// TODO: (cvle) maybe a timer to detect when accessToken has expired?
// Set the exp if it's valid.
+69 -11
View File
@@ -1,21 +1,79 @@
import React, { Component } from "react";
import React, { FunctionComponent, useEffect, useState } from "react";
import { ERROR_CODES } from "coral-common/errors";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useFetch } from "coral-framework/lib/relay";
import { CallOut, Flex, Typography } from "coral-ui/components";
import CheckInstallFetch from "./CheckInstallFetch";
import InstallWizard from "./InstallWizard";
import MainBar from "./MainBar";
import Wizard from "./Wizard";
import styles from "./App.css";
class App extends Component {
public render() {
return (
<div className={styles.root}>
<MainBar />
<div className={styles.container}>
<InstallWizard />
</div>
type State = "loading" | "success" | "failure";
const App: FunctionComponent = () => {
const [error, setError] = useState<string | null>(null);
const [state, setState] = useState<State>("loading");
const checkInstall = useFetch(CheckInstallFetch);
const context = useCoralContext();
useEffect(() => {
async function check() {
try {
await checkInstall({});
setState("success");
} catch (err) {
setError(err.message);
setState("failure");
if (err.code !== ERROR_CODES.RATE_LIMIT_EXCEEDED) {
await context.clearSession("");
}
}
}
check();
}, []);
return (
<div className={styles.root}>
<MainBar />
<div className={styles.container}>
<AppState state={state} error={error} />
</div>
);
}
</div>
);
};
interface AppStateProps {
state: State;
error: string | null;
}
const AppState: FunctionComponent<AppStateProps> = ({ state, error }) => {
switch (state) {
case "loading":
return null;
case "success":
return <InstallWizard />;
default:
return <FailedAppState error={error} />;
}
};
interface FailedAppStateProps {
error: string | null;
}
const FailedAppState: FunctionComponent<FailedAppStateProps> = ({ error }) => (
<Wizard currentStep={0}>
<Flex justifyContent="center">
<CallOut color="error">
<Typography variant="bodyCopy">{error}</Typography>
</CallOut>
</Flex>
</Wizard>
);
export default App;
@@ -0,0 +1,13 @@
import { Environment } from "relay-runtime";
import { createFetch } from "coral-framework/lib/relay";
const CheckInstallFetch = createFetch(
"checkInstallFetch",
async (environment: Environment, variables: any, { rest }) =>
await rest.fetch("/install", {
method: "GET",
})
);
export default CheckInstallFetch;
@@ -8,7 +8,10 @@ exports[`renders correctly 1`] = `
<div
className="App-container"
>
<withContext(createMutationContainer(InstallWizard)) />
<AppState
error={null}
state="loading"
/>
</div>
</div>
`;
+1
View File
@@ -0,0 +1 @@
export const INSTALL_ACCESS_TOKEN_KEY = "coral:install:accessToken";
+2
View File
@@ -4,6 +4,7 @@ import ReactDOM from "react-dom";
import { createManaged } from "coral-framework/lib/bootstrap";
import App from "./App";
import { initLocalState } from "./local";
import localesData from "./locales";
// Import css variables.
@@ -12,6 +13,7 @@ import "coral-ui/theme/variables.css";
async function main() {
const ManagedCoralContextProvider = await createManaged({
localesData,
initLocalState,
});
const Index: FunctionComponent = () => (
+1
View File
@@ -0,0 +1 @@
export { default as initLocalState } from "./initLocalState";
@@ -0,0 +1,33 @@
import { Environment } from "relay-runtime";
import { clearHash, getParamsFromHash } from "coral-framework/helpers";
import { CoralContext } from "coral-framework/lib/bootstrap";
import { initLocalBaseState } from "coral-framework/lib/relay";
import { INSTALL_ACCESS_TOKEN_KEY } from "../constants";
/**
* Initializes the local state, before we start the App.
*/
export default async function initLocalState(
environment: Environment,
context: CoralContext
) {
// Get the access token from the session storage.
let accessToken = await context.sessionStorage.getItem(
INSTALL_ACCESS_TOKEN_KEY
);
// Get all the parameters from the hash.
const params = getParamsFromHash();
if (params && params.accessToken) {
// As there's an access token in the hash, let's clear it.
clearHash();
// Save the token in session storage to override what we found.
accessToken = params.accessToken;
await context.sessionStorage.setItem(INSTALL_ACCESS_TOKEN_KEY, accessToken);
}
await initLocalBaseState(environment, context, accessToken);
}
@@ -1,3 +1,4 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { ReadyState } from "react-relay";
@@ -25,12 +26,20 @@ export const render = (data: ReadyState<QueryTypes["response"]>) => {
return <div>{data.error.message}</div>;
}
if (data.props) {
if (!data.props.story) {
return (
<Localized id="comments-streamQuery-storyNotFound">
<div>Story not found</div>
</Localized>
);
}
return (
<SpinnerWhileRendering>
<AllCommentsTabContainer
settings={data.props.settings}
viewer={data.props.viewer}
story={data.props.story!}
story={data.props.story}
/>
</SpinnerWhileRendering>
);
@@ -57,7 +66,7 @@ const AllCommentsTabQuery: FunctionComponent<Props> = props => {
viewer {
...AllCommentsTabContainer_viewer
}
story(id: $storyID, url: $storyURL) {
story: stream(id: $storyID, url: $storyURL) {
...AllCommentsTabContainer_story
@arguments(orderBy: $commentsOrderBy)
}
@@ -1,3 +1,4 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { ReadyState } from "react-relay";
@@ -23,6 +24,7 @@ export const render = (data: ReadyState<QueryTypes["response"]>) => {
if (data.error) {
return <div>{data.error.message}</div>;
}
if (!data.props) {
return (
<Flex justifyContent="center">
@@ -30,12 +32,21 @@ export const render = (data: ReadyState<QueryTypes["response"]>) => {
</Flex>
);
}
if (data.props) {
if (!data.props.story) {
return (
<Localized id="comments-streamQuery-storyNotFound">
<div>Story not found</div>
</Localized>
);
}
return (
<FeaturedCommentsContainer
settings={data.props.settings}
viewer={data.props.viewer}
story={data.props.story!}
story={data.props.story}
/>
);
}
@@ -64,7 +75,7 @@ const FeaturedCommentsQuery: FunctionComponent<Props> = props => {
viewer {
...FeaturedCommentsContainer_viewer
}
story(id: $storyID, url: $storyURL) {
story: stream(id: $storyID, url: $storyURL) {
...FeaturedCommentsContainer_story
@arguments(orderBy: $commentsOrderBy)
}
@@ -75,7 +75,7 @@ const StreamQuery: FunctionComponent<Props> = props => {
viewer {
...StreamContainer_viewer
}
story(id: $storyID, url: $storyURL) {
story: stream(id: $storyID, url: $storyURL) {
...StreamContainer_story
}
settings {
@@ -81,7 +81,7 @@ const ProfileQuery: FunctionComponent<Props> = ({
<QueryRenderer<QueryTypes>
query={graphql`
query ProfileQuery($storyID: ID, $storyURL: String) {
story(id: $storyID, url: $storyURL) {
story: stream(id: $storyID, url: $storyURL) {
...ProfileContainer_story
}
viewer {
@@ -18,38 +18,39 @@ const story = storyWithFeaturedComments;
async function createTestRenderer(
params: CreateTestRendererParams<GQLResolver> = {}
) {
const storyResolver = () => ({
...story,
featuredComments: createQueryResolverStub<StoryToCommentsResolver>(
({ variables }) => {
if (!variables.after) {
return {
edges: [story.comments.edges[0]],
pageInfo: {
endCursor: story.comments.edges[0].cursor,
hasNextPage: true,
},
};
}
expectAndFail(variables.after).toBe(story.comments.edges[0].cursor);
return {
edges: [story.comments.edges[1]],
pageInfo: {
endCursor: story.comments.edges[1].cursor,
hasNextPage: false,
},
};
}
) as any,
});
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
story: () => ({
...story,
featuredComments: createQueryResolverStub<StoryToCommentsResolver>(
({ variables }) => {
if (!variables.after) {
return {
edges: [story.comments.edges[0]],
pageInfo: {
endCursor: story.comments.edges[0].cursor,
hasNextPage: true,
},
};
}
expectAndFail(variables.after).toBe(
story.comments.edges[0].cursor
);
return {
edges: [story.comments.edges[1]],
pageInfo: {
endCursor: story.comments.edges[1].cursor,
hasNextPage: false,
},
};
}
) as any,
}),
story: storyResolver,
stream: storyResolver,
},
}),
params.resolvers
@@ -22,7 +22,7 @@ async function createTestRenderer(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
story: () => ({
stream: () => ({
...story,
featuredComments: createQueryResolverStub<StoryToCommentsResolver>(
() => {
@@ -66,6 +66,13 @@ beforeEach(() => {
.withArgs(undefined, { id: storyStub.id, url: null })
.returns(storyStub)
),
stream: createSinonStub(
s => s.throws(),
s =>
s
.withArgs(undefined, { id: storyStub.id, url: null })
.returns(storyStub)
),
settings: sinon.stub().returns(settings),
},
};
@@ -42,6 +42,13 @@ beforeEach(() => {
.withArgs(undefined, { id: storyStub.id, url: null })
.returns(storyStub)
),
stream: createSinonStub(
s => s.throws(),
s =>
s
.withArgs(undefined, { id: storyStub.id, url: null })
.returns(storyStub)
),
settings: sinon.stub().returns(settings),
},
};
@@ -35,7 +35,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => bannedUser,
story: () =>
stream: () =>
pureMerge<typeof story>(story, {
comments: {
edges: [
@@ -40,7 +40,7 @@ async function createTestRenderer(
Query: {
settings: sinon.stub().returns(settingsWithCharCount),
viewer: sinon.stub().returns(commenters[0]),
story: sinon.stub().returns(stories[0]),
stream: sinon.stub().returns(stories[0]),
...resolver.Query,
},
};
@@ -32,7 +32,7 @@ async function createTestRenderer(
Query: {
settings: sinon.stub().returns(settingsWithCharCount),
viewer: sinon.stub().returns(commenters[0]),
story: sinon.stub().returns(stories[0]),
stream: sinon.stub().returns(stories[0]),
...resolver.Query,
},
};
@@ -32,7 +32,7 @@ async function createTestRenderer(
Query: {
settings: sinon.stub().returns(settingsWithCharCount),
viewer: sinon.stub().returns(commenters[0]),
story: sinon.stub().returns(stories[0]),
stream: sinon.stub().returns(stories[0]),
...resolver.Query,
},
};
@@ -15,7 +15,7 @@ async function createTestRenderer(
...resolver,
Query: {
settings: sinon.stub().returns(settings),
story: sinon.stub().callsFake((_: any, variables: any) => {
stream: sinon.stub().callsFake((_: any, variables: any) => {
expectAndFail(variables.id).toBe(stories[0].id);
return stories[0];
}),
@@ -59,7 +59,7 @@ it("renders disabled comment stream", async () => {
it("renders closed comment stream", async () => {
const { testRenderer } = await createTestRenderer({
Query: {
story: sinon.stub().callsFake(() => ({
stream: sinon.stub().callsFake(() => ({
...stories[0],
isClosed: true,
})),
@@ -78,7 +78,7 @@ it("auto close comment stream when story closed at has been reached", async () =
const { testRenderer } = await createTestRenderer({
Query: {
story: sinon.stub().callsFake(() => ({
stream: sinon.stub().callsFake(() => ({
...stories[0],
closedAt: later.toISOString(),
isClosed: false,
@@ -23,7 +23,7 @@ function createTestRenderer(
) {
const resolvers = {
Query: {
story: createSinonStub(
stream: createSinonStub(
s => s.throws(),
s =>
s
@@ -21,7 +21,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => viewer,
story: () => story,
stream: () => story,
},
}),
params.resolvers
@@ -31,7 +31,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => viewer,
story: () => story,
stream: () => story,
},
}),
params.resolvers
@@ -133,7 +133,7 @@ async function createTestRenderer(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
story: () => story,
stream: () => story,
},
}),
params.resolvers
@@ -223,7 +223,7 @@ it("should not subscribe when story is closed", async () => {
const { testRenderer, subscriptionHandler } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Query: {
story: () => pureMerge<typeof story>(story, { isClosed: true }),
stream: () => pureMerge<typeof story>(story, { isClosed: true }),
},
}),
});
@@ -25,7 +25,7 @@ async function createTestRenderer(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
story: () => story,
stream: () => story,
},
}),
params.resolvers
@@ -123,7 +123,7 @@ it("should not subscribe when story is closed", async () => {
const { testRenderer, subscriptionHandler } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Query: {
story: () => pureMerge<typeof story>(story, { isClosed: true }),
stream: () => pureMerge<typeof story>(story, { isClosed: true }),
},
}),
});
@@ -81,6 +81,18 @@ beforeEach(() => {
)
.returns(storyStub)
),
stream: createSinonStub(
s => s.throws(),
s =>
s
.withArgs(
undefined,
sinon
.match({ id: storyStub.id, url: null })
.or(sinon.match({ id: storyStub.id }))
)
.returns(storyStub)
),
settings: sinon.stub().returns(settings),
},
};
@@ -24,7 +24,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => viewer,
story: () => story,
stream: () => story,
},
}),
params.resolvers
@@ -37,7 +37,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => viewer,
story: () => story,
stream: () => story,
},
}),
params.resolvers
@@ -30,7 +30,7 @@ async function createTestRenderer(
Query: {
settings: sinon.stub().returns(settings),
viewer: sinon.stub().returns(commenters[0]),
story: sinon.stub().callsFake((_: any, variables: any) => {
stream: sinon.stub().callsFake((_: any, variables: any) => {
expectAndFail(variables.id).toBe(stories[0].id);
return stories[0];
}),
@@ -22,7 +22,7 @@ beforeEach(() => {
Query: {
settings: sinon.stub().returns(settings),
viewer: sinon.stub().returns(commenters[0]),
story: createSinonStub(
stream: createSinonStub(
s => s.throws(),
s =>
s
@@ -26,7 +26,7 @@ async function createTestRenderer(
Query: {
settings: sinon.stub().returns(settings),
viewer: sinon.stub().returns(commenters[0]),
story: sinon.stub().callsFake((_: any, variables: any) => {
stream: sinon.stub().callsFake((_: any, variables: any) => {
expectAndFail(variables.id).toBe(stories[0].id);
return stories[0];
}),
@@ -8,7 +8,7 @@ import create from "./create";
function createTestRenderer() {
const resolvers = {
Query: {
story: sinon.stub().callsFake((_: any, data: any) => {
stream: sinon.stub().callsFake((_: any, data: any) => {
expectAndFail(data).toEqual({
id: stories[0].id,
url: null,
@@ -8,7 +8,7 @@ import create from "./create";
function createTestRenderer() {
const resolvers = {
Query: {
story: sinon.stub().returns(stories[0]),
stream: sinon.stub().returns(stories[0]),
settings: sinon.stub().returns({
...settings,
communityGuidelines: {
@@ -18,7 +18,7 @@ async function createTestRenderer(
Query: {
settings: sinon.stub().returns(pureMerge(settings, data.settings)),
viewer: sinon.stub().returns((data.loggedIn && commenters[0]) || null),
story: sinon.stub().callsFake((_: any, variables: any) => {
stream: sinon.stub().callsFake((_: any, variables: any) => {
expectAndFail(variables.id).toBe(storyWithNoComments.id);
return pureMerge(storyWithNoComments, data.story);
}),
@@ -14,7 +14,7 @@ let testRenderer: ReactTestRenderer;
beforeEach(() => {
const resolvers = {
Query: {
story: createSinonStub(
stream: createSinonStub(
s => s.throws(),
s =>
s
@@ -15,7 +15,7 @@ async function createTestRenderer(
...resolver,
Query: {
settings: sinon.stub().returns(settings),
story: sinon.stub().callsFake((_: any, variables: any) => {
stream: sinon.stub().callsFake((_: any, variables: any) => {
expectAndFail(variables.id).toBe(story.id);
return story;
}),
@@ -14,7 +14,7 @@ function createTestRenderer(
) {
const resolvers = {
Query: {
story: sinon.stub().callsFake((_: any, data: any) => {
stream: sinon.stub().callsFake((_: any, data: any) => {
expectAndFail(data).toEqual({
id: stories[0].id,
url: null,
@@ -75,7 +75,7 @@ beforeEach(() => {
s => s.throws(),
s => s.withArgs(undefined, { id: commentStub.id }).returns(commentStub)
),
story: createSinonStub(
stream: createSinonStub(
s => s.throws(),
s =>
s
@@ -19,6 +19,10 @@ beforeEach(() => {
s => s.throws(),
s => s.returns(storyWithDeepestReplies)
),
stream: createSinonStub(
s => s.throws(),
s => s.returns(storyWithDeepestReplies)
),
comment: createSinonStub(
s => s.throws(),
s =>
@@ -62,7 +62,7 @@ it("renders app with comment stream", async () => {
const { testRenderer } = await createTestRenderer({
Query: {
story: storyQueryStub,
stream: storyQueryStub,
},
});
@@ -34,7 +34,7 @@ async function createTestRenderer(
},
},
}),
story: () =>
stream: () =>
pureMerge<typeof story>(story, {
comments: {
edges: [
@@ -33,7 +33,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => viewer,
story: () => story,
stream: () => story,
},
}),
params.resolvers
@@ -24,7 +24,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => baseUser,
story: () => story,
stream: () => story,
},
}),
params.resolvers
@@ -32,7 +32,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => viewer,
story: () => story,
stream: () => story,
},
}),
params.resolvers
@@ -25,7 +25,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => baseUser,
story: () => story,
stream: () => story,
},
}),
params.resolvers
@@ -59,7 +59,7 @@ beforeEach(() => {
const resolvers = {
Query: {
settings: sinon.stub().returns(settings),
story: createSinonStub(
stream: createSinonStub(
s => s.throws(),
s =>
s
@@ -26,7 +26,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => viewer,
story: () => story,
stream: () => story,
},
}),
params.resolvers
+6
View File
@@ -327,4 +327,10 @@ export enum ERROR_CODES {
* in a row within a given time frame
*/
REPEAT_POST = "REPEAT_POST",
/**
* INSTALLATION_FORBIDDEN is returned when an installation is attempted
* when it is not authorized to do so.
*/
INSTALLATION_FORBIDDEN = "INSTALLATION_FORBIDDEN",
}
+237 -82
View File
@@ -5,12 +5,88 @@ import { LanguageCode, LOCALES } from "coral-common/helpers/i18n/locales";
import { Omit } from "coral-common/types";
import { AppOptions } from "coral-server/app";
import { validate } from "coral-server/app/request/body";
import { TenantInstalledAlreadyError } from "coral-server/errors";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import { RequestLimiter } from "coral-server/app/request/limiter";
import { Config } from "coral-server/config";
import {
InstallationForbiddenError,
TenantInstalledAlreadyError,
} from "coral-server/errors";
import { LocalProfile } from "coral-server/models/user";
import { install, InstallTenant } from "coral-server/services/tenant";
import {
createJWTSigningConfig,
extractTokenFromRequest,
JWTSigningConfig,
} from "coral-server/services/jwt";
import { verifyInstallationTokenString } from "coral-server/services/management";
import {
install,
InstallTenant,
isInstalled,
} from "coral-server/services/tenant";
import { create, CreateUser } from "coral-server/services/users";
import { RequestHandler } from "coral-server/types/express";
import { Request, RequestHandler } from "coral-server/types/express";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
export type TenantInstallCheckHandlerOptions = Pick<
AppOptions,
"redis" | "config"
>;
export const installCheckHandler = ({
config,
redis,
}: TenantInstallCheckHandlerOptions): RequestHandler => {
const { managementEnabled, signingConfig } = managementSigningConfig(config);
const limiter = new RequestLimiter({
redis,
ttl: "10s",
max: 2,
prefix: "ip",
config,
});
return async (req, res, next) => {
try {
// Limit based on the IP address.
await limiter.test(req, req.ip);
if (!req.coral) {
return next(new Error("coral was not set"));
}
if (!req.coral.cache) {
return next(new Error("cache was not set"));
}
if (req.coral.tenant) {
// There's already a Tenant on the request! No need to process further.
return next(new TenantInstalledAlreadyError());
}
// Check to see if the server already has a tenant installed.
const alreadyInstalled = await isInstalled(req.coral.cache.tenant);
if (!alreadyInstalled) {
// No tenants are installed at all, we can of course proceed with the
// install now.
return res.sendStatus(204);
}
// Check to see if management is enabled for this server.
if (managementEnabled && signingConfig) {
await checkForInstallationToken(req, signingConfig);
// We've determined that there is already a tenant installed on this
// server, but we have a valid management token, so we're good!
return res.sendStatus(204);
}
return next(new TenantInstalledAlreadyError());
} catch (err) {
return next(err);
}
};
};
export interface TenantInstallBody {
tenant: Omit<InstallTenant, "domain" | "locale"> & {
@@ -63,83 +139,162 @@ export const installHandler = ({
config,
i18n,
migrationManager,
}: TenantInstallHandlerOptions): RequestHandler => async (req, res, next) => {
try {
if (!req.coral) {
return next(new Error("coral was not set"));
}: TenantInstallHandlerOptions): RequestHandler => {
const { managementEnabled, signingConfig } = managementSigningConfig(config);
const limiter = new RequestLimiter({
redis,
ttl: "10s",
max: 1,
prefix: "ip",
config,
});
return async (req, res, next) => {
try {
// Limit based on the IP address.
await limiter.test(req, req.ip);
if (!req.coral) {
return next(new Error("coral was not set"));
}
if (!req.coral.cache) {
return next(new Error("cache was not set"));
}
if (req.coral.tenant) {
// There's already a Tenant on the request! No need to process further.
return next(new TenantInstalledAlreadyError());
}
// Check to see if the server already has a tenant installed.
let alreadyInstalled = await isInstalled(req.coral.cache.tenant);
// Check to see if management is enabled for this server.
if (managementEnabled && signingConfig) {
// Management is enabled for this server, check now if the server already
// has a tenant installed.
if (alreadyInstalled) {
await checkForInstallationToken(req, signingConfig);
// We've determined that there is at least one tenant already
// installed, and we've verified that the current call to install
// this tenant came with a signed token that was signed by the
// management secret, so we can safely mark that this tenant is
// indeed, not already been installed.
alreadyInstalled = false;
}
}
// Guard against installs trying to install multiple tenants when management
// hasn't been enabled.
if (alreadyInstalled) {
return next(new TenantInstalledAlreadyError());
}
// Validate that the payload passed in was correct, it will throw if the
// payload is invalid.
const {
tenant: { locale: tenantLocale, ...tenantInput },
user: userInput,
}: TenantInstallBody = validate(TenantInstallBodySchema, req.body);
// Default the locale to the default locale if not provided.
let locale = tenantLocale;
if (!locale) {
locale = config.get("default_locale") as LanguageCode;
}
// Install will throw if it can not create a Tenant, or it has already been
// installed.
const tenant = await install(
mongo,
redis,
req.coral.cache.tenant,
i18n,
{
...tenantInput,
// Infer the Tenant domain via the hostname parameter.
domain: req.hostname,
// Add the locale that we had to default to the default locale from the
// config.
locale,
},
req.coral.now
);
// Pull the user details out of the input for the user.
const { email, username, password } = userInput;
// Configure with profile.
const profile: LocalProfile = {
type: "local",
id: email,
password,
passwordID: uuid(),
};
// Create the first admin user.
await create(
mongo,
tenant,
{
email,
username,
profile,
role: GQLUSER_ROLE.ADMIN,
},
{},
req.coral.now
);
// Execute pending migrations to get everything installed.
await migrationManager.executePendingMigrations(mongo, true);
// Send back the Tenant.
return res.sendStatus(204);
} catch (err) {
return next(err);
}
if (!req.coral.cache) {
return next(new Error("cache was not set"));
}
if (req.coral.tenant) {
// There's already a Tenant on the request! No need to process further.
return next(new TenantInstalledAlreadyError());
}
// Validate that the payload passed in was correct, it will throw if the
// payload is invalid.
const {
tenant: { locale: tenantLocale, ...tenantInput },
user: userInput,
}: TenantInstallBody = validate(TenantInstallBodySchema, req.body);
// Default the locale to the default locale if not provided.
let locale = tenantLocale;
if (!locale) {
locale = config.get("default_locale") as LanguageCode;
}
// Install will throw if it can not create a Tenant, or it has already been
// installed.
const tenant = await install(
mongo,
redis,
req.coral.cache.tenant,
i18n,
{
...tenantInput,
// Infer the Tenant domain via the hostname parameter.
domain: req.hostname,
// Add the locale that we had to default to the default locale from the
// config.
locale,
},
req.coral.now
);
// Execute pending migrations to get everything installed.
await migrationManager.executePendingMigrations(mongo);
// Pull the user details out of the input for the user.
const { email, username, password } = userInput;
// Configure with profile.
const profile: LocalProfile = {
type: "local",
id: email,
password,
passwordID: uuid(),
};
// Create the first admin user.
await create(
mongo,
tenant,
{
email,
username,
profile,
role: GQLUSER_ROLE.ADMIN,
},
{},
req.coral.now
);
// Send back the Tenant.
return res.sendStatus(204);
} catch (err) {
return next(err);
}
};
};
async function checkForInstallationToken(
req: Request,
signingConfig: JWTSigningConfig
) {
// The server already has another tenant installed. Every additional
// tenant must be installed via the signed domain method. Check to see
// now if the given domain is signed.
const accessToken = extractTokenFromRequest(req, true);
if (accessToken) {
// Verify the JWT on the request to ensure it was signed by the
// management secret.
const { token } = await verifyInstallationTokenString(
signingConfig,
accessToken,
req.coral!.now
);
// Check to see that the domain on the token matches the hostname on
// the request.
if (req.hostname !== token.sub) {
throw new InstallationForbiddenError(req.hostname);
}
} else {
throw new InstallationForbiddenError(req.hostname);
}
}
function managementSigningConfig(config: Config) {
const managementSigningSecret = config.get("management_signing_secret");
const managementSigningAlgorithm = config.get("management_signing_algorithm");
const managementEnabled = Boolean(managementSigningSecret);
const signingConfig = managementSigningSecret
? createJWTSigningConfig(
managementSigningSecret,
managementSigningAlgorithm
)
: null;
return { managementEnabled, signingConfig };
}
+1 -1
View File
@@ -29,7 +29,7 @@ export const installedMiddleware = ({
return next(new Error("cache was not set"));
}
const installed = await isInstalled(req.coral.cache.tenant);
const installed = await isInstalled(req.coral.cache.tenant, req.hostname);
// If Coral is installed, and redirectIfInstall is true, then it will redirect.
// If Coral is not installed, and redirectIfInstall is false, then it will also
@@ -26,6 +26,7 @@ export const accessLogger: RequestHandler = (req, res, next) => {
url: req.originalUrl || req.url,
method: req.method,
statusCode: res.statusCode,
host: req.hostname,
userAgent,
responseTime,
},
+5 -1
View File
@@ -1,6 +1,7 @@
/* eslint-disable max-classes-per-file */
import { Redis } from "ioredis";
import { DateTime } from "luxon";
import ms from "ms";
import { Omit } from "coral-common/types";
@@ -68,7 +69,10 @@ export class Limiter {
}
if (tries > this.max) {
throw new RateLimitExceeded(key, this.max, tries);
const resetsAt = DateTime.fromJSDate(new Date())
.plus({ seconds: this.ttl })
.toJSDate();
throw new RateLimitExceeded(key, this.max, resetsAt, tries);
}
return tries;
+3 -8
View File
@@ -5,7 +5,6 @@ import { AppOptions } from "coral-server/app";
import {
graphQLHandler,
healthHandler,
installHandler,
versionHandler,
} from "coral-server/app/handlers";
import { JSONErrorHandler } from "coral-server/app/middleware/error";
@@ -18,6 +17,7 @@ import { tenantMiddleware } from "coral-server/app/middleware/tenant";
import { createNewAccountRouter } from "./account";
import { createNewAuthRouter } from "./auth";
import { createNewInstallRouter } from "./install";
import { createStoryRouter } from "./story";
import { createNewUserRouter } from "./user";
@@ -39,13 +39,8 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
// Configure the Health route.
router.get("/health", healthHandler);
// Installation middleware.
router.use(
"/install",
jsonMiddleware,
tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }),
installHandler(app)
);
// Installation router.
router.use("/install", createNewInstallRouter(app));
// Tenant identification middleware. All requests going past this point can
// only proceed if there is a valid Tenant for the hostname.
+25
View File
@@ -0,0 +1,25 @@
import express, { Router } from "express";
import { AppOptions } from "coral-server/app";
import { installCheckHandler, installHandler } from "coral-server/app/handlers";
import { jsonMiddleware } from "coral-server/app/middleware/json";
import { tenantMiddleware } from "coral-server/app/middleware/tenant";
export function createNewInstallRouter(app: AppOptions): Router {
// Create a router.
const router = express.Router();
router.get(
"/",
tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }),
installCheckHandler(app)
);
router.post(
"/",
jsonMiddleware,
tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }),
installHandler(app)
);
return router;
}
+29 -12
View File
@@ -45,6 +45,18 @@ convict.addFormat({
coerce: (url: string) => (url ? ensureEndSlash(url) : url),
});
const algorithms = [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
];
const config = convict({
env: {
doc: "The application environment.",
@@ -150,22 +162,27 @@ const config = convict({
sensitive: true,
},
signing_algorithm: {
doc: "",
format: [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
],
doc: "The signing algorithm used to sign JSON Web Tokens (JWT).",
format: algorithms,
default: "HS256",
env: "SIGNING_ALGORITHM",
arg: "signingAlgorithm",
},
management_signing_secret: {
doc: "The secret used to verify management API requests.",
format: "*",
default: null,
env: "MANAGEMENT_SIGNING_SECRET",
arg: "managementSigningSecret",
sensitive: true,
},
management_signing_algorithm: {
doc: "The algorithm used to sign management API requests",
format: algorithms,
default: "HS256",
env: "MANAGEMENT_SIGNING_ALGORITHM",
arg: "managementSigningAlgorithm",
},
logging_level: {
doc: "The logging level to print to the console",
format: ["fatal", "error", "warn", "info", "debug", "trace"],
+12 -2
View File
@@ -530,6 +530,16 @@ export class TenantInstalledAlreadyError extends CoralError {
}
}
export class InstallationForbiddenError extends CoralError {
constructor(domain: string) {
super({
code: ERROR_CODES.INSTALLATION_FORBIDDEN,
status: 401,
context: { pub: { domain } },
});
}
}
export class InvalidCredentialsError extends CoralError {
constructor(reason: string) {
super({
@@ -685,11 +695,11 @@ export class InviteTokenExpired extends CoralError {
}
export class RateLimitExceeded extends CoralError {
constructor(resource: string, max: number, tries?: number) {
constructor(resource: string, max: number, resetsAt: Date, tries?: number) {
super({
code: ERROR_CODES.RATE_LIMIT_EXCEEDED,
status: 429,
context: { pvt: { resource, max, tries } },
context: { pvt: { resource, max, tries, resetsAt } },
});
}
}
+1
View File
@@ -57,4 +57,5 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
USER_ALREADY_PREMOD: "error-userAlreadyPremod",
INVITE_INCLUDES_EXISTING_USER: "error-inviteIncludesExistingUser",
REPEAT_POST: "error-repeatPost",
INSTALLATION_FORBIDDEN: "error-installationForbidden",
};
@@ -3,7 +3,8 @@ import { GQLQueryTypeResolver } from "coral-server/graph/tenant/schema/__generat
import { moderationQueuesResolver } from "./ModerationQueues";
export const Query: Required<GQLQueryTypeResolver<void>> = {
story: (source, args, ctx) =>
story: (source, args, ctx) => ctx.loaders.Stories.find.load(args),
stream: (source, args, ctx) =>
ctx.tenant.stories.disableLazy
? ctx.loaders.Stories.find.load(args)
: ctx.loaders.Stories.findOrCreate.load(args),
@@ -2597,6 +2597,12 @@ type Query {
"""
story(id: ID, url: String): Story
"""
stream will load a specific story that can be identified by either an ID or a
URL and will create the story if that feature is enabled.
"""
stream(id: ID, url: String): Story
"""
stories returns filtered stories that can be paginated.
"""
+4 -1
View File
@@ -127,7 +127,10 @@ class Server {
this.i18n = new I18n(defaultLocale);
// Create the signing config.
this.signingConfig = createJWTSigningConfig(this.config);
this.signingConfig = createJWTSigningConfig(
this.config.get("signing_secret"),
this.config.get("signing_algorithm")
);
}
/**
+2 -1
View File
@@ -36,7 +36,7 @@ error-emailInvalidFormat =
error-emailExceedsMaxLength =
Email address exceeds maximum length of {$max} characters.
error-internalError = Internal Error
error-tenantInstalledAlready = Tenant has already been installed already.
error-tenantInstalledAlready = Tenant has already been installed.
error-userNotEntitled = You are not authorized to access that resource.
error-storyNotFound = Story ({$storyID}) not found.
error-commentNotFound = Comment ({$commentID}) not found.
@@ -59,3 +59,4 @@ error-persistedQueryNotFound = The persisted query with ID { $id } was not found
error-rawQueryNotAuthorized = You are not authorized to execute this query.
error-inviteIncludesExistingUser = A user with the email address { $email } already exists.
error-repeatPost = Are you sure? This comment is very similar to your previous comment.
error-installationForbidden = { -product-name } is already installed. To install another Tenant on this domain ({ $domain }) you need to generate an installation token.
@@ -148,6 +148,7 @@ export async function updateStoryCounts(
const update: DeepPartial<Story> = { commentCounts };
const $inc = pickBy(dotize(update), identity);
if (isEmpty($inc)) {
// Nothing needs to be incremented, just return the story.
return retrieveStory(mongo, tenantID, id);
}
+10 -1
View File
@@ -1,3 +1,4 @@
import { isEmpty } from "lodash";
import { Db } from "mongodb";
import uuid from "uuid";
@@ -259,11 +260,19 @@ export async function updateTenant(
id: string,
update: UpdateTenantInput
) {
const $set = dotize(update, { embedArrays: true });
// Check to see if there is any updates that will be made.
if (isEmpty($set)) {
// No updates need to be made, abort here and just return the tenant.
return retrieveTenant(mongo, id);
}
// Get the tenant from the database.
const result = await collection(mongo).findOneAndUpdate(
{ id },
// Only update fields that have been updated.
{ $set: dotize(update, { embedArrays: true }) },
{ $set },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
@@ -52,7 +52,7 @@ export const userRateLimit: IntermediateModerationPhase = async ({
.plus({ seconds: COMMENT_LIMIT_WINDOW_SECONDS })
.toJSDate();
if (nextEditTime > now) {
throw new RateLimitExceeded("createComment", 1);
throw new RateLimitExceeded("createComment", 1, nextEditTime);
}
return;
+1 -10
View File
@@ -1,6 +1,3 @@
import sinon from "sinon";
import { Config } from "coral-server/config";
import {
createJWTSigningConfig,
extractTokenFromRequest,
@@ -50,14 +47,8 @@ describe("extractJWTFromRequest", () => {
describe("createJWTSigningConfig", () => {
it("parses a RSA certificate", () => {
const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`;
const config = {
get: sinon.stub(),
};
config.get.withArgs("signing_secret").returns(input);
config.get.withArgs("signing_algorithm").returns("RS256");
const signingConfig = createJWTSigningConfig((config as any) as Config);
const signingConfig = createJWTSigningConfig(input, "RS256");
expect(signingConfig.algorithm).toEqual("RS256");
expect(signingConfig.secret.toString()).toMatchSnapshot();
+4 -6
View File
@@ -9,7 +9,6 @@ import uuid from "uuid/v4";
import { DEFAULT_SESSION_LENGTH } from "coral-common/constants";
import { Omit } from "coral-common/types";
import { Config } from "coral-server/config";
import {
AuthenticationError,
JWTRevokedError,
@@ -230,12 +229,11 @@ function isAsymmetricSigningAlgorithm(
/**
* Parses the config and provides the signing config.
*
* @param config the server configuration
*/
export function createJWTSigningConfig(config: Config): JWTSigningConfig {
const secret = config.get("signing_secret");
const algorithm = config.get("signing_algorithm");
export function createJWTSigningConfig(
secret: string,
algorithm: string = SymmetricSigningAlgorithm.HS256
): JWTSigningConfig {
if (isSymmetricSigningAlgorithm(algorithm)) {
return createSymmetricSigningConfig(algorithm, secret);
} else if (isAsymmetricSigningAlgorithm(algorithm)) {
@@ -0,0 +1 @@
export * from "./management";
@@ -0,0 +1,58 @@
import Joi from "joi";
import { isNull } from "lodash";
import { TokenInvalidError } from "coral-server/errors";
import {
JWTSigningConfig,
StandardClaims,
StandardClaimsSchema,
verifyJWT,
} from "../jwt";
export interface InstallationToken
extends Required<Pick<StandardClaims, "iat" | "exp" | "sub">> {
// aud specifies `installation` as the audience to indicate that this is a
// installation token.
aud: "installation";
}
const InstallationTokenSchema = StandardClaimsSchema.keys({
aud: Joi.string().only("installation"),
}).requiredKeys(["iat", "exp", "sub", "aud"]);
export function validateInstallationToken(
token: InstallationToken | object
): Error | null {
const { error } = Joi.validate(token, InstallationTokenSchema);
return error || null;
}
export function isInstallationToken(
token: InstallationToken | object
): token is InstallationToken {
return isNull(validateInstallationToken(token));
}
export async function verifyInstallationTokenString(
signingConfig: JWTSigningConfig,
tokenString: string,
now: Date
) {
const token = verifyJWT(tokenString, signingConfig, now, {
// Verify that this is a installation token based on the audience.
audience: "installation",
});
// Validate that this is indeed a installation token.
if (!isInstallationToken(token)) {
// TODO: (wyattjoh) look into a way of pulling the error into this one
throw new TokenInvalidError(
tokenString,
"does not conform to the installation token schema"
);
}
// Now that we've verified that the token is valid, we're good to go!
return { token };
}
+5 -1
View File
@@ -133,9 +133,13 @@ export default class Manager {
return records.length > 0 ? records[records.length - 1] : null;
}
public async executePendingMigrations(mongo: Db) {
public async executePendingMigrations(mongo: Db, silent = false) {
// Error out if this is ran twice.
if (this.ran) {
if (silent) {
return;
}
throw new Error("pending migrations have already been executed");
}
+7 -2
View File
@@ -1,4 +1,4 @@
import { zip } from "lodash";
import { uniq, zip } from "lodash";
import { Db } from "mongodb";
import { StoryURLInvalidError } from "coral-server/errors";
@@ -270,8 +270,13 @@ export async function merge(
return null;
}
// Get the stories referenced.
// Collect the story id's and check for duplicates.
const storyIDs = [destinationID, ...sourceIDs];
if (uniq(storyIDs).length !== storyIDs.length) {
throw new Error("cannot merge from/to the same story ID");
}
// Get the stories referenced.
const stories = await retrieveManyStories(mongo, tenant.id, storyIDs);
// Ensure that these are all defined.
+4 -1
View File
@@ -300,7 +300,10 @@ export default class TenantCache {
JSON.stringify(message)
);
logger.debug({ tenantID: tenant.id, subscribers }, "updated tenant");
logger.debug(
{ tenantID: tenant.id, subscribers },
"updated tenant in cache"
);
// Publish the event for the connected listeners.
this.emitter.emit(EMITTER_EVENT_NAME, tenant);
+19 -3
View File
@@ -80,8 +80,22 @@ export async function update(
* isInstalled will return a promise that if true, indicates that a Tenant has
* been installed.
*/
export async function isInstalled(cache: TenantCache) {
return (await cache.count()) > 0;
export async function isInstalled(cache: TenantCache, domain?: string) {
const count = await cache.count();
if (count === 0) {
return false;
}
if (domain) {
const tenant = await cache.retrieveByDomain(domain);
if (tenant) {
return true;
}
return false;
}
return true;
}
export type InstallTenant = CreateTenantInput;
@@ -94,7 +108,9 @@ export async function install(
input: InstallTenant,
now = new Date()
) {
if (await isInstalled(cache)) {
// Ensure that this Tenant isn't being installed onto a domain that already
// exists.
if (await isInstalled(cache, input.domain)) {
throw new TenantInstalledAlreadyError();
}
+4 -1
View File
@@ -1251,7 +1251,10 @@ export async function updateUserLastWroteCommentTimestamp(
.expire(key, COMMENT_LIMIT_WINDOW_SECONDS)
.exec();
if (!set) {
throw new RateLimitExceeded("createComment", 1);
const resetsAt = DateTime.fromJSDate(when)
.plus({ seconds: COMMENT_LIMIT_WINDOW_SECONDS })
.toJSDate();
throw new RateLimitExceeded("createComment", 1, resetsAt);
}
}