From 656e735c4b1535fd9c372aaa1ac05ad25a2c7910 Mon Sep 17 00:00:00 2001
From: Richard Liu
Date: Thu, 1 Feb 2024 13:28:06 -0800
Subject: [PATCH] [generic issuance] pipeline CRUD app with minimal UI (#1451)
Closes #1428
TODO
- [x] Request types
- [x] Move requests to passport-interface
- [x] Integrating with pipeline DB
- [x] Integrating with user DB
- [x] DB testing
- [ ] proposed refreshPipeline() in-memory command
https://github.com/proofcarryingdata/zupass/issues/1456
- [ ] end-to-end API tests
https://github.com/proofcarryingdata/zupass/issues/1457
What's in here:
- CRUD endpoints for pipeline definitions
- MVP UI that allows interacting with this CRUD UI via JSON (see video
below)
- Basic 'permission control' for the CRUD actions, e.g., you must own a
pipeline to delete it, but you can read or write to a pipeline if you
are either an owner or editor
- Pipeline Definition schema validation on the backend
What's not in here:
- The best state management / client-side fetching system
- Any admin user logic
- Comprehensive tests (in progress)
https://github.com/proofcarryingdata/zupass/assets/36896271/c3277ca4-caca-4249-868f-cc167c80f896
---
apps/generic-issuance-client/.env.example | 5 +-
apps/generic-issuance-client/build.ts | 8 +-
apps/generic-issuance-client/package.json | 2 +
apps/generic-issuance-client/src/constants.ts | 16 +-
apps/generic-issuance-client/src/main.tsx | 4 +-
.../src/pages/Dashboard.tsx | 118 +++++++++--
.../src/pages/Pipeline.tsx | 126 +++++++++++
apps/generic-issuance-client/tsconfig.json | 19 +-
apps/passport-server/src/application.ts | 2 +
.../database/queries/pipelineDefinitionDB.ts | 23 ++-
.../src/database/queries/pipelineUserDB.ts | 71 +++++++
.../routing/routes/genericIssuanceRoutes.ts | 63 +++++-
apps/passport-server/src/routing/server.ts | 12 +-
.../genericIssuanceService.ts | 115 ++++++++++-
.../pipelines/LemonadePipeline.ts | 77 +------
.../pipelines/PretixPipeline.ts | 55 +----
.../generic-issuance/pipelines/types.ts | 49 +----
apps/passport-server/src/types.ts | 2 +
.../MockPipelineDefinitionDB.ts | 6 +-
.../generic-issuance/MockPipelineUserDB.ts | 4 +-
.../test/genericIssuance.spec.ts | 114 ++++++++--
.../passport-interface/src/RequestTypes.ts | 27 +++
.../passport-interface/src/api/makeRequest.ts | 111 +++++++++-
.../requestGenericIssuanceDeletePipeline.ts | 26 +++
...questGenericIssuanceGetAllUserPipelines.ts | 26 +++
.../api/requestGenericIssuanceGetPipeline.ts | 27 +++
.../requestGenericIssuanceUpsertPipeline.ts | 30 +++
.../passport-interface/src/genericIssuance.ts | 195 ++++++++++++++++++
packages/lib/passport-interface/src/index.ts | 5 +
yarn.lock | 18 ++
30 files changed, 1104 insertions(+), 252 deletions(-)
create mode 100644 apps/generic-issuance-client/src/pages/Pipeline.tsx
create mode 100644 packages/lib/passport-interface/src/api/requestGenericIssuanceDeletePipeline.ts
create mode 100644 packages/lib/passport-interface/src/api/requestGenericIssuanceGetAllUserPipelines.ts
create mode 100644 packages/lib/passport-interface/src/api/requestGenericIssuanceGetPipeline.ts
create mode 100644 packages/lib/passport-interface/src/api/requestGenericIssuanceUpsertPipeline.ts
create mode 100644 packages/lib/passport-interface/src/genericIssuance.ts
diff --git a/apps/generic-issuance-client/.env.example b/apps/generic-issuance-client/.env.example
index 782b383fe1..c6a140dbf7 100644
--- a/apps/generic-issuance-client/.env.example
+++ b/apps/generic-issuance-client/.env.example
@@ -1,3 +1,6 @@
# This token is used to authenticate Stytch requests for user authentication.
# If you are on the team, message @rrrliu to get one.
-STYTCH_PUBLIC_TOKEN=
\ No newline at end of file
+STYTCH_PUBLIC_TOKEN=
+
+PASSPORT_CLIENT_URL="http://localhost:3000"
+PASSPORT_SERVER_URL="http://localhost:3002"
\ No newline at end of file
diff --git a/apps/generic-issuance-client/build.ts b/apps/generic-issuance-client/build.ts
index 82a7d1179d..ad9c1259e6 100644
--- a/apps/generic-issuance-client/build.ts
+++ b/apps/generic-issuance-client/build.ts
@@ -12,7 +12,13 @@ const genericIssuanceClientAppOpts: BuildOptions = {
define: {
"process.env.NODE_ENV": `'${process.env.NODE_ENV}'`,
"process.env.STYTCH_PUBLIC_TOKEN": `'${process.env.STYTCH_PUBLIC_TOKEN}'`,
- "process.env.GENERIC_ISSUANCE_CLIENT_URL": `'${process.env.GENERIC_ISSUANCE_CLIENT_URL}'`
+ "process.env.GENERIC_ISSUANCE_CLIENT_URL": `'${process.env.GENERIC_ISSUANCE_CLIENT_URL}'`,
+ "process.env.PASSPORT_SERVER_URL": JSON.stringify(
+ process.env.PASSPORT_SERVER_URL || "http://localhost:3002"
+ ),
+ "process.env.PASSPORT_CLIENT_URL": JSON.stringify(
+ process.env.PASSPORT_CLIENT_URL || "http://localhost:3000"
+ )
},
entryPoints: ["src/main.tsx"],
plugins: [
diff --git a/apps/generic-issuance-client/package.json b/apps/generic-issuance-client/package.json
index 6add00a144..29471575eb 100644
--- a/apps/generic-issuance-client/package.json
+++ b/apps/generic-issuance-client/package.json
@@ -12,11 +12,13 @@
"clean": "rm -rf node_modules public/js tsconfig.tsbuildinfo"
},
"dependencies": {
+ "@pcd/passport-interface": "0.10.0",
"@stytch/react": "^15.0.0",
"@stytch/vanilla-js": "^4.4.2",
"dotenv": "^16.4.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-redux": "^9.1.0",
"react-router-dom": "^6.9.0",
"styled-components": "^5.3.6"
},
diff --git a/apps/generic-issuance-client/src/constants.ts b/apps/generic-issuance-client/src/constants.ts
index 293c34ca7b..257f5dbf05 100644
--- a/apps/generic-issuance-client/src/constants.ts
+++ b/apps/generic-issuance-client/src/constants.ts
@@ -1,16 +1,4 @@
export const PCD_GITHUB_URL = "https://github.com/proofcarryingdata/pcd";
-export const IS_PROD = process.env.NODE_ENV === "production";
-export const IS_STAGING = process.env.NODE_ENV === "staging";
-
-export const ZUPASS_URL = IS_PROD
- ? "https://zupass.org/"
- : IS_STAGING
- ? "https://staging.zupass.org/"
- : "http://localhost:3000/";
-
-export const ZUPASS_SERVER_URL = IS_PROD
- ? "https://api.zupass.org/"
- : IS_STAGING
- ? "https://api-staging.zupass.org/"
- : "http://localhost:3002/";
+export const ZUPASS_URL = process.env.PASSPORT_CLIENT_URL;
+export const ZUPASS_SERVER_URL = process.env.PASSPORT_SERVER_URL;
diff --git a/apps/generic-issuance-client/src/main.tsx b/apps/generic-issuance-client/src/main.tsx
index f3065d0c99..d18376b4b8 100644
--- a/apps/generic-issuance-client/src/main.tsx
+++ b/apps/generic-issuance-client/src/main.tsx
@@ -6,12 +6,14 @@ import { createHashRouter, RouterProvider } from "react-router-dom";
import { GlobalStyle } from "./components/GlobalStyle";
import Dashboard from "./pages/Dashboard";
import Home from "./pages/Home";
+import Pipeline from "./pages/Pipeline";
const stytch = new StytchUIClient(process.env.STYTCH_PUBLIC_TOKEN);
const router = createHashRouter([
{ path: "/", element: },
- { path: "/dashboard", element: }
+ { path: "/dashboard", element: },
+ { path: "/pipelines/:id", element: }
]);
createRoot(document.getElementById("root") as HTMLElement).render(
diff --git a/apps/generic-issuance-client/src/pages/Dashboard.tsx b/apps/generic-issuance-client/src/pages/Dashboard.tsx
index 29ecd90ff1..72fa5a2fc1 100644
--- a/apps/generic-issuance-client/src/pages/Dashboard.tsx
+++ b/apps/generic-issuance-client/src/pages/Dashboard.tsx
@@ -1,24 +1,75 @@
+import {
+ PipelineDefinition,
+ requestGenericIssuanceGetAllUserPipelines,
+ requestGenericIssuanceUpsertPipeline
+} from "@pcd/passport-interface";
import { useStytch, useStytchUser } from "@stytch/react";
-import { ReactNode, useEffect, useState } from "react";
+import { ReactNode, useCallback, useEffect, useState } from "react";
+import { Link } from "react-router-dom";
import { ZUPASS_SERVER_URL } from "../constants";
+const SAMPLE_CREATE_PIPELINE_TEXT = JSON.stringify(
+ {
+ type: "Lemonade",
+ editorUserIds: [],
+ options: {
+ lemonadeApiKey: "your-lemonade-api-key",
+ events: []
+ }
+ },
+ null,
+ 2
+);
+
export default function Dashboard(): ReactNode {
const stytchClient = useStytch();
const { user } = useStytchUser();
const [isLoggingOut, setLoggingOut] = useState(false);
- const [userPingMessage, setUserPingMessage] = useState("");
+ // TODO: After MVP, replace with RTK hooks or a more robust state management.
+ const [pipelines, setPipelines] = useState([]);
+ const [isLoading, setLoading] = useState(true);
+ const [isCreatingPipeline, setCreatingPipeline] = useState(false);
+ const [newPipelineRaw, setNewPipelineRaw] = useState(
+ SAMPLE_CREATE_PIPELINE_TEXT
+ );
const [error, setError] = useState("");
- useEffect(() => {
- setUserPingMessage("Pinging server...");
- fetch(new URL("generic-issuance/api/user/ping", ZUPASS_SERVER_URL).href, {
- credentials: "include"
- })
- .then((res) => res.json())
- .then((message) => setUserPingMessage(`JWT valid, received ${message}.`))
- .catch((e) => setUserPingMessage(`Error: ${e}`));
+ const fetchAllPipelines = useCallback(async () => {
+ setLoading(true);
+ const res =
+ await requestGenericIssuanceGetAllUserPipelines(ZUPASS_SERVER_URL);
+ if (res.success) {
+ setPipelines(res.value);
+ } else {
+ // TODO: Better errors
+ alert(`An error occurred while fetching user pipelines: ${res.error}`);
+ }
+ setLoading(false);
}, []);
+ useEffect(() => {
+ fetchAllPipelines();
+ }, [fetchAllPipelines]);
+
+ const createPipeline = async (): Promise => {
+ if (!newPipelineRaw) return;
+ const res = await requestGenericIssuanceUpsertPipeline(
+ ZUPASS_SERVER_URL,
+ JSON.parse(newPipelineRaw)
+ );
+ await fetchAllPipelines();
+ if (res.success) {
+ setCreatingPipeline(false);
+ } else {
+ // TODO: Better errors
+ alert(`An error occurred while creating pipeline: ${res.error}`);
+ }
+ };
+
+ if (isLoading) {
+ return Loading...
;
+ }
+
if (!user) {
window.location.href = "/";
}
@@ -36,20 +87,53 @@ export default function Dashboard(): ReactNode {
Congrats - you are now logged in as {user.emails?.[0]?.email}.
- {userPingMessage && {userPingMessage}
}
+
+ My Pipelines
+ {!pipelines.length && No pipelines right now - go create some!
}
+ {!!pipelines.length && (
+
+ {pipelines.map((p) => (
+
+ -
+ id: {p.id}, type: {p.type}
+
+
+ ))}
+
+ )}
+
+
+ {isCreatingPipeline && (
+
+ )}
+
);
}
diff --git a/apps/generic-issuance-client/src/pages/Pipeline.tsx b/apps/generic-issuance-client/src/pages/Pipeline.tsx
new file mode 100644
index 0000000000..a0cbb76ca2
--- /dev/null
+++ b/apps/generic-issuance-client/src/pages/Pipeline.tsx
@@ -0,0 +1,126 @@
+import {
+ PipelineDefinition,
+ requestGenericIssuanceDeletePipeline,
+ requestGenericIssuanceGetPipeline,
+ requestGenericIssuanceUpsertPipeline
+} from "@pcd/passport-interface";
+import { useStytchUser } from "@stytch/react";
+import { ReactNode, useEffect, useState } from "react";
+import { Link, useParams } from "react-router-dom";
+import { ZUPASS_SERVER_URL } from "../constants";
+
+function format(obj: object): string {
+ return JSON.stringify(obj, null, 2);
+}
+
+export default function Pipeline(): ReactNode {
+ const params = useParams();
+ const { user } = useStytchUser();
+ const { id } = params;
+ // TODO: After MVP, replace with RTK hooks or a more robust state management.
+ const [savedPipeline, setSavedPipeline] = useState();
+ const [textareaValue, setTextareaValue] = useState("");
+ const [queryLoading, setQueryLoading] = useState(true);
+ const [saveLoading, setSaveLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ async function savePipeline(): Promise {
+ setSaveLoading(true);
+ const res = await requestGenericIssuanceUpsertPipeline(
+ ZUPASS_SERVER_URL,
+ JSON.parse(textareaValue)
+ );
+ if (res.success) {
+ setSavedPipeline(res.value);
+ setTextareaValue(format(res.value));
+ setError("");
+ } else {
+ setError(`An error occured while saving: ${res.error}`);
+ }
+ setSaveLoading(false);
+ }
+
+ async function deletePipeline(): Promise {
+ if (confirm("Are you sure you would like to delete this pipeline?")) {
+ const res = await requestGenericIssuanceDeletePipeline(
+ ZUPASS_SERVER_URL,
+ id
+ );
+ if (res.success) {
+ window.location.href = "/#/dashboard";
+ } else {
+ setError(`An error occured while deleting: ${res.error}`);
+ }
+ }
+ }
+
+ useEffect(() => {
+ async function fetchPipeline(): Promise {
+ const res = await requestGenericIssuanceGetPipeline(
+ ZUPASS_SERVER_URL,
+ id
+ );
+ if (res.success) {
+ setSavedPipeline(res.value);
+ setTextareaValue(format(res.value));
+ setError("");
+ } else {
+ setError(
+ `This pipeline "${id}" is invalid or you do not have access to this pipeline.`
+ );
+ setSavedPipeline(undefined);
+ }
+ setQueryLoading(false);
+ }
+ fetchPipeline();
+ }, [id]);
+
+ if (!user) {
+ window.location.href = "/";
+ }
+
+ if (queryLoading) {
+ return Loading...
;
+ }
+
+ const hasEdits = format(savedPipeline) !== textareaValue;
+
+ return (
+
+ {savedPipeline && (
+ <>
+
+
+
+ {hasEdits && (
+
+ )}
+ {!hasEdits && }
+
+
+
+
+ >
+ )}
+ {error && (
+
+ Error:
+ {error}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/generic-issuance-client/tsconfig.json b/apps/generic-issuance-client/tsconfig.json
index 9a25c0ca8b..0706b1d4ca 100644
--- a/apps/generic-issuance-client/tsconfig.json
+++ b/apps/generic-issuance-client/tsconfig.json
@@ -3,12 +3,25 @@
"resolveJsonModule": true,
"downlevelIteration": true,
"jsx": "react-jsx",
- "lib": ["ES2015", "DOM"],
+ "lib": [
+ "ES2015",
+ "DOM"
+ ],
"esModuleInterop": true,
// To allow for mocha and jest to work together:
// https://stackoverflow.com/a/65568463
"skipLibCheck": true
},
- "include": ["**/*.ts", "**/*.tsx"],
- "exclude": ["node_modules"]
+ "include": [
+ "**/*.ts",
+ "**/*.tsx"
+ ],
+ "exclude": [
+ "node_modules"
+ ],
+ "references": [
+ {
+ "path": "../../packages/lib/passport-interface"
+ }
+ ]
}
diff --git a/apps/passport-server/src/application.ts b/apps/passport-server/src/application.ts
index 2164acf770..2e73387e25 100644
--- a/apps/passport-server/src/application.ts
+++ b/apps/passport-server/src/application.ts
@@ -4,6 +4,7 @@ import process from "node:process";
import * as path from "path";
import urljoin from "url-join";
import { MockPipelineAtomDB } from "../test/generic-issuance/MockPipelineAtomDB";
+import { MockPipelineUserDB } from "../test/generic-issuance/MockPipelineUserDB";
import { getDevconnectPretixAPI } from "./apis/devconnect/devconnectPretixAPI";
import { IEmailAPI, sendgridSendEmail } from "./apis/emailAPI";
import { getHoneycombAPI } from "./apis/honeycombAPI";
@@ -51,6 +52,7 @@ export async function startApplication(
publicResourcesDir: path.join(process.cwd(), "public"),
gitCommitHash: await getCommitHash(),
// TODO: remove these once we have settled on a db schema for these
+ pipelineUserDB: new MockPipelineUserDB(),
pipelineAtomDB: new MockPipelineAtomDB()
};
diff --git a/apps/passport-server/src/database/queries/pipelineDefinitionDB.ts b/apps/passport-server/src/database/queries/pipelineDefinitionDB.ts
index 58a4e9ec2a..63966b7d75 100644
--- a/apps/passport-server/src/database/queries/pipelineDefinitionDB.ts
+++ b/apps/passport-server/src/database/queries/pipelineDefinitionDB.ts
@@ -1,9 +1,6 @@
+import { PipelineDefinition, PipelineType } from "@pcd/passport-interface";
import _ from "lodash";
import { Pool, PoolClient } from "postgres-pool";
-import {
- PipelineDefinition,
- PipelineType
-} from "../../services/generic-issuance/pipelines/types";
import { GenericIssuancePipelineRow } from "../models";
import { sqlQuery, sqlTransaction } from "../sqlQuery";
@@ -16,6 +13,7 @@ import { sqlQuery, sqlTransaction } from "../sqlQuery";
*/
export interface IPipelineDefinitionDB {
loadPipelineDefinitions(): Promise;
+ clearDefinition(definitionID: string): Promise;
clearAllDefinitions(): Promise;
getDefinition(definitionID: string): Promise;
setDefinition(definition: PipelineDefinition): Promise;
@@ -63,6 +61,23 @@ export class PipelineDefinitionDB implements IPipelineDefinitionDB {
await sqlQuery(this.db, "DELETE FROM generic_issuance_pipelines");
}
+ public async clearDefinition(definitionID: string): Promise {
+ await sqlTransaction(
+ this.db,
+ "Delete pipeline definition",
+ async (client) => {
+ await client.query(
+ "DELETE FROM generic_issuance_pipeline_editors WHERE pipeline_id = $1",
+ [definitionID]
+ );
+ await client.query(
+ "DELETE FROM generic_issuance_pipelines WHERE id = $1",
+ [definitionID]
+ );
+ }
+ );
+ }
+
public async getDefinition(
definitionID: string
): Promise {
diff --git a/apps/passport-server/src/database/queries/pipelineUserDB.ts b/apps/passport-server/src/database/queries/pipelineUserDB.ts
index d41e71815c..923161bf25 100644
--- a/apps/passport-server/src/database/queries/pipelineUserDB.ts
+++ b/apps/passport-server/src/database/queries/pipelineUserDB.ts
@@ -1,4 +1,7 @@
+import { Pool } from "postgres-pool";
import { PipelineUser } from "../../services/generic-issuance/pipelines/types";
+import { GenericIssuanceUserRow } from "../models";
+import { sqlQuery } from "../sqlQuery";
export interface IPipelineUserDB {
loadUsers(): Promise;
@@ -7,3 +10,71 @@ export interface IPipelineUserDB {
getUserByEmail(email: string): Promise;
setUser(user: PipelineUser): Promise;
}
+
+export class PipelineUserDB implements IPipelineUserDB {
+ private db: Pool;
+
+ public constructor(db: Pool) {
+ this.db = db;
+ }
+
+ private dbRowToPipelineUser(row: GenericIssuanceUserRow): PipelineUser {
+ return {
+ id: row.id,
+ email: row.email,
+ isAdmin: row.is_admin
+ };
+ }
+
+ public async loadUsers(): Promise {
+ const result = await sqlQuery(
+ this.db,
+ "SELECT * FROM generic_issuance_users"
+ );
+ return result.rows.map(this.dbRowToPipelineUser);
+ }
+
+ public async clearAllUsers(): Promise {
+ await sqlQuery(this.db, "DELETE FROM generic_issuance_users");
+ }
+
+ public async getUser(userID: string): Promise {
+ const result = await sqlQuery(
+ this.db,
+ "SELECT * FROM generic_issuance_users WHERE id = $1",
+ [userID]
+ );
+ if (result.rowCount === 0) {
+ return undefined;
+ } else {
+ return this.dbRowToPipelineUser(result.rows[0]);
+ }
+ }
+
+ public async getUserByEmail(
+ email: string
+ ): Promise {
+ const result = await sqlQuery(
+ this.db,
+ "SELECT * FROM generic_issuance_users WHERE email = $1",
+ [email]
+ );
+ if (result.rowCount === 0) {
+ return undefined;
+ } else {
+ return this.dbRowToPipelineUser(result.rows[0]);
+ }
+ }
+
+ public async setUser(user: PipelineUser): Promise {
+ await sqlQuery(
+ this.db,
+ `
+ INSERT INTO generic_issuance_users (id, email, is_admin) VALUES($1, $2, $3)
+ ON CONFLICT(id) DO UPDATE
+ SET (email, is_admin) = ($2, $3)
+ `,
+ [user.id, user.email, user.isAdmin]
+ );
+ }
+}
diff --git a/apps/passport-server/src/routing/routes/genericIssuanceRoutes.ts b/apps/passport-server/src/routing/routes/genericIssuanceRoutes.ts
index 7909d8be38..09f1a21017 100644
--- a/apps/passport-server/src/routing/routes/genericIssuanceRoutes.ts
+++ b/apps/passport-server/src/routing/routes/genericIssuanceRoutes.ts
@@ -1,11 +1,14 @@
import {
GenericIssuanceCheckInRequest,
GenericIssuanceCheckInResponseValue,
+ GenericIssuanceDeletePipelineResponseValue,
+ GenericIssuanceGetAllUserPipelinesResponseValue,
+ GenericIssuanceGetPipelineResponseValue,
GenericIssuanceSendEmailResponseValue,
+ GenericIssuanceUpsertPipelineRequest,
PollFeedRequest,
PollFeedResponseValue
} from "@pcd/passport-interface";
-import cookierParser from "cookie-parser";
import express from "express";
import { GenericIssuanceService } from "../../services/generic-issuance/genericIssuanceService";
import { GlobalServices } from "../../types";
@@ -18,7 +21,6 @@ export function initGenericIssuanceRoutes(
{ genericIssuanceService }: GlobalServices
): void {
logger("[INIT] initializing generic issuance routes");
- app.use(cookierParser());
/**
* Throws if we don't have an instance of {@link GenericIssuanceService}.
@@ -78,13 +80,62 @@ export function initGenericIssuanceRoutes(
}
);
- // temporary -- just for testing JWT authentication
app.get(
- "/generic-issuance/api/user/ping",
+ "/generic-issuance/api/pipelines",
async (req: express.Request, res: express.Response) => {
checkGenericIssuanceServiceStarted(genericIssuanceService);
- await genericIssuanceService.authenticateStytchSession(req);
- res.json("pong");
+ const { id } =
+ await genericIssuanceService.authenticateStytchSession(req);
+ res.send(
+ (await genericIssuanceService.getAllUserPipelineDefinitions(
+ id
+ )) satisfies GenericIssuanceGetAllUserPipelinesResponseValue
+ );
+ }
+ );
+
+ app.get(
+ "/generic-issuance/api/pipelines/:id",
+ async (req: express.Request, res: express.Response) => {
+ checkGenericIssuanceServiceStarted(genericIssuanceService);
+ const { id: userId } =
+ await genericIssuanceService.authenticateStytchSession(req);
+ res.send(
+ (await genericIssuanceService.getPipelineDefinition(
+ userId,
+ checkUrlParam(req, "id")
+ )) satisfies GenericIssuanceGetPipelineResponseValue
+ );
+ }
+ );
+
+ app.put(
+ "/generic-issuance/api/pipelines",
+ async (req: express.Request, res: express.Response) => {
+ checkGenericIssuanceServiceStarted(genericIssuanceService);
+ const { id: userId } =
+ await genericIssuanceService.authenticateStytchSession(req);
+ res.send(
+ (await genericIssuanceService.upsertPipelineDefinition(
+ userId,
+ req.body as GenericIssuanceUpsertPipelineRequest
+ )) satisfies GenericIssuanceUpsertPipelineRequest
+ );
+ }
+ );
+
+ app.delete(
+ "/generic-issuance/api/pipelines/:id",
+ async (req: express.Request, res: express.Response) => {
+ checkGenericIssuanceServiceStarted(genericIssuanceService);
+ const { id: userId } =
+ await genericIssuanceService.authenticateStytchSession(req);
+ res.send(
+ (await genericIssuanceService.deletePipelineDefinition(
+ userId,
+ checkUrlParam(req, "id")
+ )) satisfies GenericIssuanceDeletePipelineResponseValue
+ );
}
);
}
diff --git a/apps/passport-server/src/routing/server.ts b/apps/passport-server/src/routing/server.ts
index dbfa18e1fb..5f45d4f5c5 100644
--- a/apps/passport-server/src/routing/server.ts
+++ b/apps/passport-server/src/routing/server.ts
@@ -1,3 +1,4 @@
+import cookieParser from "cookie-parser";
import cors, { CorsOptions } from "cors";
import express, { Application, NextFunction } from "express";
import * as fs from "fs";
@@ -47,12 +48,12 @@ export async function startHttpServer(
app.use(morgan("tiny"));
}
+ app.use(cookieParser());
app.use(
express.json({
limit: "5mb"
})
);
- app.use(cors());
app.use(tracingMiddleware());
app.use(
cors((req, callback) => {
@@ -67,15 +68,20 @@ export async function startHttpServer(
process.env.GENERIC_ISSUANCE_CLIENT_URL;
let corsOptions: CorsOptions;
+ const methods = ["GET", "POST", "PUT", "DELETE"];
if (
genericIssuanceClientUrl != null &&
req.header("Origin") === genericIssuanceClientUrl
) {
- corsOptions = { origin: genericIssuanceClientUrl, credentials: true };
+ corsOptions = {
+ origin: genericIssuanceClientUrl,
+ credentials: true,
+ methods
+ };
} else {
corsOptions = {
origin: "*",
- methods: ["GET", "POST", "PUT", "DELETE"]
+ methods
};
}
diff --git a/apps/passport-server/src/services/generic-issuance/genericIssuanceService.ts b/apps/passport-server/src/services/generic-issuance/genericIssuanceService.ts
index 9ef1b91fdc..1872469860 100644
--- a/apps/passport-server/src/services/generic-issuance/genericIssuanceService.ts
+++ b/apps/passport-server/src/services/generic-issuance/genericIssuanceService.ts
@@ -3,11 +3,14 @@ import {
CheckTicketInResponseValue,
GenericIssuanceCheckInRequest,
GenericIssuanceSendEmailResponseValue,
+ PipelineDefinition,
+ PipelineDefinitionSchema,
PollFeedRequest,
PollFeedResponseValue
} from "@pcd/passport-interface";
import { Request } from "express";
import stytch, { Client, Session } from "stytch";
+import { v4 as uuidV4 } from "uuid";
import { ILemonadeAPI } from "../../apis/lemonade/lemonadeAPI";
import { IGenericPretixAPI } from "../../apis/pretix/genericPretixAPI";
import { IPipelineAtomDB } from "../../database/queries/pipelineAtomDB";
@@ -15,6 +18,10 @@ import {
IPipelineDefinitionDB,
PipelineDefinitionDB
} from "../../database/queries/pipelineDefinitionDB";
+import {
+ IPipelineUserDB,
+ PipelineUserDB
+} from "../../database/queries/pipelineUserDB";
import { PCDHTTPError } from "../../routing/pcdHttpError";
import { ApplicationContext } from "../../types";
import { logger } from "../../util/logger";
@@ -34,7 +41,7 @@ import {
PretixPipeline,
isPretixPipelineDefinition
} from "./pipelines/PretixPipeline";
-import { Pipeline, PipelineDefinition } from "./pipelines/types";
+import { Pipeline, PipelineUser } from "./pipelines/types";
const SERVICE_NAME = "GENERIC_ISSUANCE";
const LOG_TAG = `[${SERVICE_NAME}]`;
@@ -123,6 +130,7 @@ export function createPipeline(
export class GenericIssuanceService {
private context: ApplicationContext;
private pipelines: Pipeline[];
+ private userDB: IPipelineUserDB;
private definitionDB: IPipelineDefinitionDB;
private atomDB: IPipelineAtomDB;
private lemonadeAPI: ILemonadeAPI;
@@ -143,6 +151,7 @@ export class GenericIssuanceService {
eddsaPrivateKey: string,
zupassPublicKey: EdDSAPublicKey
) {
+ this.userDB = new PipelineUserDB(context.dbPool);
this.definitionDB = new PipelineDefinitionDB(context.dbPool);
this.atomDB = atomDB;
this.context = context;
@@ -258,6 +267,104 @@ export class GenericIssuanceService {
return relevantCapability.checkin(req);
}
+ public async getAllUserPipelineDefinitions(
+ userId: string
+ ): Promise {
+ // TODO: Add logic for isAdmin = true
+ return (await this.definitionDB.loadPipelineDefinitions()).filter((d) =>
+ this.userHasPipelineDefinitionAccess(userId, d)
+ );
+ }
+
+ private userHasPipelineDefinitionAccess(
+ userId: string,
+ pipeline: PipelineDefinition
+ ): boolean {
+ return (
+ pipeline.ownerUserId === userId || pipeline.editorUserIds.includes(userId)
+ );
+ }
+
+ public async getPipelineDefinition(
+ userId: string,
+ pipelineId: string
+ ): Promise {
+ const pipeline = await this.definitionDB.getDefinition(pipelineId);
+ if (!pipeline || !this.userHasPipelineDefinitionAccess(userId, pipeline))
+ throw new PCDHTTPError(404, "Pipeline not found or not accessible");
+ return pipeline;
+ }
+
+ public async upsertPipelineDefinition(
+ userId: string,
+ pipelineDefinition: PipelineDefinition
+ ): Promise {
+ const existingPipelineDefinition = await this.definitionDB.getDefinition(
+ pipelineDefinition.id
+ );
+ if (existingPipelineDefinition) {
+ if (
+ !this.userHasPipelineDefinitionAccess(
+ userId,
+ existingPipelineDefinition
+ )
+ ) {
+ throw new PCDHTTPError(403, "Not allowed to edit pipeline");
+ }
+ if (
+ existingPipelineDefinition.ownerUserId !==
+ pipelineDefinition.ownerUserId
+ ) {
+ throw new PCDHTTPError(400, "Cannot change owner of pipeline");
+ }
+ } else {
+ pipelineDefinition.ownerUserId = userId;
+ if (!pipelineDefinition.id) {
+ pipelineDefinition.id = uuidV4();
+ }
+ }
+
+ let newPipelineDefinition: PipelineDefinition;
+ try {
+ newPipelineDefinition = PipelineDefinitionSchema.parse(
+ pipelineDefinition
+ ) as PipelineDefinition;
+ } catch (e) {
+ throw new PCDHTTPError(400, `Invalid formatted response: ${e}`);
+ }
+
+ await this.definitionDB.setDefinition(newPipelineDefinition);
+ return newPipelineDefinition;
+ }
+
+ public async deletePipelineDefinition(
+ userId: string,
+ pipelineId: string
+ ): Promise {
+ const pipeline = await this.getPipelineDefinition(userId, pipelineId);
+ // TODO: Finalize the "permissions model" for CRUD actions. Right now,
+ // create, read, update are permissable by owners and editors, while delete
+ // is only permissable by owners.
+ if (pipeline.ownerUserId !== userId) {
+ throw new PCDHTTPError(403, "Need to be owner to delete pipeline");
+ }
+ await this.definitionDB.clearDefinition(pipelineId);
+ }
+
+ public async createOrGetUser(email: string): Promise {
+ const existingUser = await this.userDB.getUserByEmail(email);
+ if (existingUser != null) {
+ return existingUser;
+ }
+ const newUser: PipelineUser = {
+ id: uuidV4(),
+ email,
+ isAdmin: false
+ };
+ this.userDB.setUser(newUser);
+ return newUser;
+ }
+
/**
* TODO: this probably shouldn't be public, but it was useful for testing.
*/
@@ -275,12 +382,14 @@ export class GenericIssuanceService {
return email;
}
- public async authenticateStytchSession(req: Request): Promise {
+ public async authenticateStytchSession(req: Request): Promise {
try {
const { session } = await this.stytchClient.sessions.authenticateJwt({
session_jwt: req.cookies["stytch_session_jwt"]
});
- return this.getEmailFromStytchSession(session);
+ const email = this.getEmailFromStytchSession(session);
+ const user = await this.createOrGetUser(email);
+ return user;
} catch (e) {
throw new PCDHTTPError(401, "Not authorized");
}
diff --git a/apps/passport-server/src/services/generic-issuance/pipelines/LemonadePipeline.ts b/apps/passport-server/src/services/generic-issuance/pipelines/LemonadePipeline.ts
index f58531c66b..11e6c97d5b 100644
--- a/apps/passport-server/src/services/generic-issuance/pipelines/LemonadePipeline.ts
+++ b/apps/passport-server/src/services/generic-issuance/pipelines/LemonadePipeline.ts
@@ -11,6 +11,9 @@ import {
CheckTicketInResponseValue,
GenericCheckinCredentialPayload,
GenericIssuanceCheckInRequest,
+ LemonadePipelineDefinition,
+ PipelineDefinition,
+ PipelineType,
PollFeedRequest,
PollFeedResponseValue,
verifyFeedCredential
@@ -34,89 +37,17 @@ import {
generateIssuanceUrlPath
} from "../capabilities/FeedIssuanceCapability";
import { PipelineCapability } from "../capabilities/types";
-import {
- BasePipeline,
- BasePipelineDefinition,
- Pipeline,
- PipelineDefinition,
- PipelineType
-} from "./types";
+import { BasePipeline, Pipeline } from "./types";
const LOG_NAME = "LemonadePipeline";
const LOG_TAG = `[${LOG_NAME}]`;
-/**
- * A {@link LemonadePipelineDefinition} is a pipeline that has finished being
- * set up that configures the generic issuance service to load data on behalf
- * of a particular user from Lemonade and issue tickets for it.
- */
-export interface LemonadePipelineDefinition extends BasePipelineDefinition {
- type: PipelineType.Lemonade;
- options: LemonadePipelineOptions;
-}
-
export function isLemonadePipelineDefinition(
d: PipelineDefinition
): d is LemonadePipelineDefinition {
return d.type === PipelineType.Lemonade;
}
-/**
- * Generic Issuance-specific event configuration. Should roughly match up to the
- * types defined above - {@link LemonadeTicket}, {@link LemonadeEvent}, and
- * {@link LemonadeTicketTier}.
- */
-export interface LemonadePipelineEventConfig {
- /**
- * The ID of this event on the Lemonade end.
- */
- externalId: string;
-
- /**
- * Display name.
- */
- name: string;
-
- /**
- * The UUID of this event used for {@link EdDSATicketPCD}.
- */
- genericIssuanceEventId: string;
-
- /**
- * Roughly translates to Products in {@link EdDSATicketPCD}.
- */
- ticketTiers: LemonadePipelineTicketTierConfig[];
-}
-
-/**
- * Generic Issuance-specific ticket tier configuration - roughly corresponds to a
- * 'Product' in Pretix-land.
- */
-export interface LemonadePipelineTicketTierConfig {
- /**
- * The ID of this ticket tier on the Lemonade end.
- */
- externalId: string;
-
- /**
- * The UUID of this ticket tier used in {@link EdDSATicketPCD}.
- */
- genericIssuanceProductId: string;
-
- /**
- * Whether this ticket tier is allowed to check other tickets in or not.
- */
- isSuperUser: boolean;
-}
-
-/**
- * Configured by the user when setting up Lemonade as a data source.
- */
-export interface LemonadePipelineOptions {
- lemonadeApiKey: string;
- events: LemonadePipelineEventConfig[];
-}
-
/**
* Class encapsulating the complete set of behaviors that a {@link Pipeline} which
* loads data from Lemonade is capable of.
diff --git a/apps/passport-server/src/services/generic-issuance/pipelines/PretixPipeline.ts b/apps/passport-server/src/services/generic-issuance/pipelines/PretixPipeline.ts
index d1200c5070..48dbea5b34 100644
--- a/apps/passport-server/src/services/generic-issuance/pipelines/PretixPipeline.ts
+++ b/apps/passport-server/src/services/generic-issuance/pipelines/PretixPipeline.ts
@@ -11,8 +11,13 @@ import {
CheckTicketInResponseValue,
GenericCheckinCredentialPayload,
GenericIssuanceCheckInRequest,
+ PipelineDefinition,
+ PipelineType,
PollFeedRequest,
PollFeedResponseValue,
+ PretixEventConfig,
+ PretixPipelineDefinition,
+ PretixProductConfig,
verifyFeedCredential
} from "@pcd/passport-interface";
import { PCDActionType } from "@pcd/pcd-collection";
@@ -45,51 +50,11 @@ import {
generateIssuanceUrlPath
} from "../capabilities/FeedIssuanceCapability";
import { PipelineCapability } from "../capabilities/types";
-import {
- BasePipeline,
- BasePipelineDefinition,
- Pipeline,
- PipelineDefinition,
- PipelineType
-} from "./types";
+import { BasePipeline, Pipeline } from "./types";
const LOG_NAME = "PretixPipeline";
const LOG_TAG = `[${LOG_NAME}]`;
-/**
- * This object represents a configuration from which the server can instantiate
- * a functioning {@link PretixPipeline}. Partially specified by the user.
- */
-export interface PretixPipelineOptions {
- pretixAPIKey: string;
- pretixOrgUrl: string;
- events: PretixEventConfig[];
-}
-
-/**
- * Configuration for a specific event, which is managed under the organizer's
- * Pretix account.
- */
-export interface PretixEventConfig {
- externalId: string; // Pretix's event ID
- genericIssuanceId: string; // Our UUID
- name: string; // Display name for the event
- products: PretixProductConfig[];
-}
-
-/**
- * Configuration for specific products available for the event. Does not need
- * to include all products available in Pretix, but any product listed here
- * must be available in Pretix.
- */
-export interface PretixProductConfig {
- externalId: string; // Pretix's item ID
- genericIssuanceId: string; // Our UUID
- name: string; // Display name
- isSuperUser: boolean; // Is a user with this product a "superuser"?
- // Superusers are able to check tickets in to events.
-}
-
/**
* Class encapsulating the complete set of behaviors that a {@link Pipeline} which
* loads data from Pretix is capable of.
@@ -784,14 +749,6 @@ export class PretixPipeline implements BasePipeline {
}
}
-/**
- * Similar to {@link LemonadePipelineDefinition} but for Pretix-based Pipelines.
- */
-export interface PretixPipelineDefinition extends BasePipelineDefinition {
- type: PipelineType.Pretix;
- options: PretixPipelineOptions;
-}
-
export function isPretixPipelineDefinition(
d: PipelineDefinition
): d is PretixPipelineDefinition {
diff --git a/apps/passport-server/src/services/generic-issuance/pipelines/types.ts b/apps/passport-server/src/services/generic-issuance/pipelines/types.ts
index 678cc55419..a837b5dff3 100644
--- a/apps/passport-server/src/services/generic-issuance/pipelines/types.ts
+++ b/apps/passport-server/src/services/generic-issuance/pipelines/types.ts
@@ -1,9 +1,7 @@
+import { PipelineType } from "@pcd/passport-interface";
import { BasePipelineCapability } from "../types";
-import {
- LemonadePipeline,
- LemonadePipelineDefinition
-} from "./LemonadePipeline";
-import { PretixPipeline, PretixPipelineDefinition } from "./PretixPipeline";
+import { LemonadePipeline } from "./LemonadePipeline";
+import { PretixPipeline } from "./PretixPipeline";
/**
* Each new type of {@link Pipeline} needs to be added to this type
@@ -19,47 +17,6 @@ export interface BasePipeline {
capabilities: readonly BasePipelineCapability[];
load(): Promise; // TODO: is this right?
}
-
-/**
- * Each new {@link Pipeline} type needs a corresponding entry in thie enum.
- */
-export enum PipelineType {
- Lemonade = "Lemonade",
- Pretix = "Pretix"
-}
-
-/**
- * A pipeline definition is owned by the user who set it up. It's the
- * persisted representation of a pipeline on our backend. When a user
- * sets up a pipeline via the generic issuance UI, they are creating one
- * of these over a series of configuration steps - choosing which data
- * source to use, uploading an API key, selecting which data to load, etc.
- *
- * TODO:
- * - sql migration to create a table to store these things. Probably
- * something like a 2-column table. One column for JSON representing
- * the pipeline definition, and a unique id column derived from the JSON.
- */
-export interface BasePipelineDefinition {
- id: string;
- ownerUserId: string;
- editorUserIds: string[];
-}
-
-/**
- * Any new pipeline definitions need to be added to this type declaration. Note
- * that the way I've set it up a {@link Pipeline} appears to only be able to have
- * one data source. However, that is not the case. In the future, if needed, it
- * would be possible to create Pipelines that load from an arbitrary quantity
- * of data sources.
- */
-export type PipelineDefinition =
- | LemonadePipelineDefinition
- | PretixPipelineDefinition;
-
-/**
- * TODO - should be a database entry
- */
export interface PipelineUser {
id: string;
email: string;
diff --git a/apps/passport-server/src/types.ts b/apps/passport-server/src/types.ts
index 071bce703f..b310f3b127 100644
--- a/apps/passport-server/src/types.ts
+++ b/apps/passport-server/src/types.ts
@@ -8,6 +8,7 @@ import { IGenericPretixAPI } from "./apis/pretix/genericPretixAPI";
import { IZuconnectTripshaAPI } from "./apis/zuconnect/zuconnectTripshaAPI";
import { IZuzaluPretixAPI } from "./apis/zuzaluPretixAPI";
import { IPipelineAtomDB } from "./database/queries/pipelineAtomDB";
+import { IPipelineUserDB } from "./database/queries/pipelineUserDB";
import {
DevconnectPretixAPIFactory,
DevconnectPretixSyncService
@@ -40,6 +41,7 @@ export interface ApplicationContext {
gitCommitHash: string;
/// WIP. remove once we have real database APIs for these
+ pipelineUserDB: IPipelineUserDB;
pipelineAtomDB: IPipelineAtomDB;
}
diff --git a/apps/passport-server/test/generic-issuance/MockPipelineDefinitionDB.ts b/apps/passport-server/test/generic-issuance/MockPipelineDefinitionDB.ts
index e3d1fc60bd..02fad58a4a 100644
--- a/apps/passport-server/test/generic-issuance/MockPipelineDefinitionDB.ts
+++ b/apps/passport-server/test/generic-issuance/MockPipelineDefinitionDB.ts
@@ -1,5 +1,5 @@
+import { PipelineDefinition } from "@pcd/passport-interface";
import { IPipelineDefinitionDB } from "../../src/database/queries/pipelineDefinitionDB";
-import { PipelineDefinition } from "../../src/services/generic-issuance/pipelines/types";
/**
* For testing. In-memory representation of all the pipelines that
@@ -12,6 +12,10 @@ export class MockPipelineDefinitionDB implements IPipelineDefinitionDB {
this.definitions = {};
}
+ public async clearDefinition(definitionID: string): Promise {
+ delete this.definitions[definitionID];
+ }
+
public async clearAllDefinitions(): Promise {
this.definitions = {};
}
diff --git a/apps/passport-server/test/generic-issuance/MockPipelineUserDB.ts b/apps/passport-server/test/generic-issuance/MockPipelineUserDB.ts
index b488261c1f..dd0173b94d 100644
--- a/apps/passport-server/test/generic-issuance/MockPipelineUserDB.ts
+++ b/apps/passport-server/test/generic-issuance/MockPipelineUserDB.ts
@@ -25,9 +25,9 @@ export class MockPipelineUserDB implements IPipelineUserDB {
}
public async getUserByEmail(
- userID: string
+ email: string
): Promise {
- return this.users[userID];
+ return Object.values(this.users).find((u) => (u.email = email));
}
public async loadUsers(): Promise {
diff --git a/apps/passport-server/test/genericIssuance.spec.ts b/apps/passport-server/test/genericIssuance.spec.ts
index 61e97f6a0a..efc4560ecf 100644
--- a/apps/passport-server/test/genericIssuance.spec.ts
+++ b/apps/passport-server/test/genericIssuance.spec.ts
@@ -4,6 +4,9 @@ import { EmailPCDPackage } from "@pcd/email-pcd";
import {
FeedCredentialPayload,
GenericIssuanceCheckInResult,
+ LemonadePipelineDefinition,
+ PipelineType,
+ PretixPipelineDefinition,
createFeedCredentialPayload,
createGenericCheckinCredentialPayload,
requestGenericIssuanceCheckin,
@@ -22,21 +25,16 @@ import { randomUUID } from "crypto";
import "mocha";
import { SetupServer } from "msw/node";
import * as path from "path";
+import { v4 } from "uuid";
import { ILemonadeAPI } from "../src/apis/lemonade/lemonadeAPI";
import { getI18nString } from "../src/apis/pretix/genericPretixAPI";
import { stopApplication } from "../src/application";
import { PipelineDefinitionDB } from "../src/database/queries/pipelineDefinitionDB";
-import { sqlQuery } from "../src/database/sqlQuery";
+import { PipelineUserDB } from "../src/database/queries/pipelineUserDB";
import { GenericIssuanceService } from "../src/services/generic-issuance/genericIssuanceService";
-import {
- LemonadePipeline,
- LemonadePipelineDefinition
-} from "../src/services/generic-issuance/pipelines/LemonadePipeline";
-import {
- PretixPipeline,
- PretixPipelineDefinition
-} from "../src/services/generic-issuance/pipelines/PretixPipeline";
-import { PipelineType } from "../src/services/generic-issuance/pipelines/types";
+import { LemonadePipeline } from "../src/services/generic-issuance/pipelines/LemonadePipeline";
+import { PretixPipeline } from "../src/services/generic-issuance/pipelines/PretixPipeline";
+import { PipelineUser } from "../src/services/generic-issuance/pipelines/types";
import { Zupass } from "../src/types";
import { LemonadeDataMocker } from "./lemonade/LemonadeDataMocker";
import { MockLemonadeAPI } from "./lemonade/MockLemonadeAPI";
@@ -44,7 +42,7 @@ import { GenericPretixDataMocker } from "./pretix/GenericPretixDataMocker";
import { getGenericMockPretixAPIServer } from "./pretix/MockGenericPretixServer";
import { overrideEnvironment, testingEnv } from "./util/env";
import { startTestingApp } from "./util/startTestingApplication";
-import { expectToExist } from "./util/util";
+import { expectToExist, randomEmail } from "./util/util";
export async function semaphoreSignPayload(
identity: Identity,
@@ -163,6 +161,16 @@ export async function requestCheckInGenericTicket(
);
}
+function assertUserMatches(
+ expectedUser: PipelineUser,
+ actualUser: PipelineUser | undefined
+): void {
+ expect(actualUser).to.exist;
+ expect(actualUser?.email).to.eq(expectedUser.email);
+ expect(actualUser?.id).to.eq(expectedUser.id);
+ expect(actualUser?.isAdmin).to.eq(expectedUser.isAdmin);
+}
+
/**
* Rough test of the generic issuance functionality defined in this PR, just
* to make sure that ends are coming together neatly. Totally incomplete.
@@ -301,12 +309,12 @@ describe("generic issuance service tests", function () {
lemonadeAPI
});
- // TODO: remove this once we have user management
- await sqlQuery(
- application.context.dbPool,
- "INSERT INTO generic_issuance_users VALUES($1, $2, $3)",
- [pipelineOwnerUUID, "test@example.com", true]
- );
+ const userDB = new PipelineUserDB(application.context.dbPool);
+ await userDB.setUser({
+ id: pipelineOwnerUUID,
+ email: "test@example.com",
+ isAdmin: true
+ });
const orgUrls = mockPretixData.get().organizersByOrgUrl.keys();
mockPretixServer = getGenericMockPretixAPIServer(orgUrls, mockPretixData);
@@ -328,6 +336,18 @@ describe("generic issuance service tests", function () {
mockPretixServer.resetHandlers();
});
+ it("endpoints test", async () => {
+ // TODO: Add more tests here
+ expectToExist(giService);
+ const pretixUserPipelines = await giService.getAllUserPipelineDefinitions(
+ pretixDefinition.ownerUserId
+ );
+ expect(pretixUserPipelines).to.have.deep.members([
+ pretixDefinition,
+ lemonadeDefinition
+ ]);
+ });
+
it("test", async () => {
expectToExist(giService);
const pipelines = await giService.getAllPipelines();
@@ -474,6 +494,61 @@ describe("generic issuance service tests", function () {
expect(thirdCheckinResult.success).to.eq(false);
});
+ it("test user DB", async function () {
+ const userDB = new PipelineUserDB(application.context.dbPool);
+
+ const adminUser = await userDB.getUser(pipelineOwnerUUID);
+ assertUserMatches(
+ { id: pipelineOwnerUUID, email: "test@example.com", isAdmin: true },
+ adminUser
+ );
+ expect(adminUser).to.exist;
+ expect(adminUser?.email).to.eq("test@example.com");
+ expect(adminUser?.id).to.eq(pipelineOwnerUUID);
+ expect(adminUser?.isAdmin).to.be.true;
+
+ const user1: PipelineUser = {
+ id: v4(),
+ email: randomEmail(),
+ isAdmin: false
+ };
+ await userDB.setUser(user1);
+ const user2: PipelineUser = {
+ id: v4(),
+ email: randomEmail(),
+ isAdmin: false
+ };
+ await userDB.setUser(user2);
+
+ assertUserMatches(user1, await userDB.getUser(user1.id));
+ assertUserMatches(user2, await userDB.getUser(user2.id));
+ expect(await userDB.loadUsers()).to.have.deep.members([
+ adminUser,
+ user1,
+ user2
+ ]);
+
+ const updatedUser1: PipelineUser = {
+ id: user1.id,
+ email: randomEmail(),
+ isAdmin: true
+ };
+ await userDB.setUser(updatedUser1);
+
+ assertUserMatches(updatedUser1, await userDB.getUser(user1.id));
+ assertUserMatches(user2, await userDB.getUserByEmail(user2.email));
+ assertUserMatches(
+ updatedUser1,
+ await userDB.getUserByEmail(updatedUser1.email)
+ );
+ expect(await userDB.getUserByEmail(user1.email)).to.be.undefined;
+ expect(await userDB.loadUsers()).to.have.deep.members([
+ adminUser,
+ updatedUser1,
+ user2
+ ]);
+ });
+
it("test definition DB", async function () {
const definitionDB = new PipelineDefinitionDB(application.context.dbPool);
await definitionDB.clearAllDefinitions();
@@ -521,6 +596,11 @@ describe("generic issuance service tests", function () {
)) as PretixPipelineDefinition;
expect(emptyEditorsDefinition).to.exist;
expect(emptyEditorsDefinition.editorUserIds).to.be.empty;
+ await definitionDB.clearDefinition(emptyEditorsDefinition.id);
+ const deletedDefinition = await definitionDB.getDefinition(
+ emptyEditorsDefinition.id
+ );
+ expect(deletedDefinition).to.be.undefined;
}
});
diff --git a/packages/lib/passport-interface/src/RequestTypes.ts b/packages/lib/passport-interface/src/RequestTypes.ts
index d27f054372..8d6ce4554b 100644
--- a/packages/lib/passport-interface/src/RequestTypes.ts
+++ b/packages/lib/passport-interface/src/RequestTypes.ts
@@ -12,6 +12,7 @@ import {
import { PendingPCDStatus } from "./PendingPCDUtils";
import { Feed } from "./SubscriptionManager";
import { NamedAPIError } from "./api/apiResult";
+import { PipelineDefinition } from "./genericIssuance";
/**
* Ask the server to prove a PCD. The server reponds with a {@link PendingPCD}
@@ -826,3 +827,29 @@ export type GenericIssuanceCheckInResponseValue = undefined;
* Sending email either succeeds or fails, so no response value is defined for now.
*/
export type GenericIssuanceSendEmailResponseValue = undefined;
+
+/**
+ * Returns all pipeline definitions that a user has access to.
+ */
+export type GenericIssuanceGetAllUserPipelinesResponseValue =
+ PipelineDefinition[];
+
+/**
+ * Returns the requested pipeline definition.
+ */
+export type GenericIssuanceGetPipelineResponseValue = PipelineDefinition;
+
+/**
+ * Request body containing the pipeline definition to be upserted.
+ */
+export type GenericIssuanceUpsertPipelineRequest = PipelineDefinition;
+
+/**
+ * Returns the upserted pipeline definition.
+ */
+export type GenericIssuanceUpsertPipelineResponseValue = PipelineDefinition;
+
+/**
+ * Deleting a pipeline definition either succeeds or fails, so no response value is defined for now.
+ */
+export type GenericIssuanceDeletePipelineResponseValue = undefined;
diff --git a/packages/lib/passport-interface/src/api/makeRequest.ts b/packages/lib/passport-interface/src/api/makeRequest.ts
index b522ad034b..6aa0c959f1 100644
--- a/packages/lib/passport-interface/src/api/makeRequest.ts
+++ b/packages/lib/passport-interface/src/api/makeRequest.ts
@@ -9,14 +9,16 @@ import { POST } from "./constants";
export async function httpGet>(
url: string,
opts: ResultMapper,
- query?: object
+ query?: object,
+ includeCredentials = false
): Promise {
return httpRequest(
urlJoin(
url,
"?" + new URLSearchParams((query as Record) ?? {})
),
- opts
+ opts,
+ includeCredentials
);
}
@@ -26,9 +28,33 @@ export async function httpGet>(
export async function httpPost>(
url: string,
opts: ResultMapper,
- postBody?: object
+ postBody?: object,
+ includeCredentials = false
): Promise {
- return httpRequest(url, opts, postBody);
+ return httpRequest(url, opts, includeCredentials, "POST", postBody);
+}
+
+/**
+ * Wrapper of {@link httpRequest} that sends a PUT request.
+ */
+export async function httpPut>(
+ url: string,
+ opts: ResultMapper,
+ putBody?: object,
+ includeCredentials = false
+): Promise {
+ return httpRequest(url, opts, includeCredentials, "PUT", putBody);
+}
+
+/**
+ * Wrapper of {@link httpRequest} that sends a DE:ETE request.
+ */
+export async function httpDelete>(
+ url: string,
+ opts: ResultMapper,
+ includeCredentials = false
+): Promise {
+ return httpRequest(urlJoin(url), opts, includeCredentials, "DELETE");
}
/**
@@ -37,7 +63,8 @@ export async function httpPost>(
export async function httpGetSimple(
url: string,
onValue: GetResultValue>,
- query?: object
+ query?: object,
+ includeCredentials = false
): Promise> {
return httpGet>(
url,
@@ -49,7 +76,8 @@ export async function httpGetSimple(
code
})
},
- query
+ query,
+ includeCredentials
);
}
@@ -59,7 +87,8 @@ export async function httpGetSimple(
export async function httpPostSimple(
url: string,
onValue: GetResultValue>,
- postBody?: object
+ postBody?: object,
+ includeCredentials = false
): Promise> {
return httpPost>(
url,
@@ -71,7 +100,54 @@ export async function httpPostSimple(
code
})
},
- postBody
+ postBody,
+ includeCredentials
+ );
+}
+
+/**
+ * Shorthand for a {@link httpPut} whose error type is a string.
+ */
+export async function httpPutSimple(
+ url: string,
+ onValue: GetResultValue>,
+ putBody?: object,
+ includeCredentials = false
+): Promise> {
+ return httpPut>(
+ url,
+ {
+ onValue,
+ onError: async (resText, code) => ({
+ error: resText,
+ success: false,
+ code
+ })
+ },
+ putBody,
+ includeCredentials
+ );
+}
+
+/**
+ * Shorthand for a {@link httpDelete} whose error type is a string.
+ */
+export async function httpDeleteSimple(
+ url: string,
+ onValue: GetResultValue>,
+ includeCredentials = false
+): Promise> {
+ return httpDelete>(
+ url,
+ {
+ onValue,
+ onError: async (resText, code) => ({
+ error: resText,
+ success: false,
+ code
+ })
+ },
+ includeCredentials
);
}
@@ -92,7 +168,9 @@ const throttleMs = 0;
async function httpRequest>(
url: string,
opts: ResultMapper,
- postBody?: object
+ includeCredentials: boolean,
+ method?: "GET" | "POST" | "PUT" | "DELETE",
+ requestBody?: object
): Promise {
await sleep(throttleMs);
@@ -100,14 +178,25 @@ async function httpRequest>(
method: "GET"
};
- if (postBody != null) {
+ if (includeCredentials) {
+ requestOptions = {
+ ...requestOptions,
+ credentials: "include"
+ };
+ }
+
+ if (requestBody != null) {
requestOptions = {
...requestOptions,
...POST,
- body: JSON.stringify(postBody)
+ body: JSON.stringify(requestBody)
};
}
+ if (method != null) {
+ requestOptions = { ...requestOptions, method };
+ }
+
try {
// @todo - prevent logspam in the same way we prevent it
// on the server. otherwise, these show up in test logs.
diff --git a/packages/lib/passport-interface/src/api/requestGenericIssuanceDeletePipeline.ts b/packages/lib/passport-interface/src/api/requestGenericIssuanceDeletePipeline.ts
new file mode 100644
index 0000000000..bb2237722d
--- /dev/null
+++ b/packages/lib/passport-interface/src/api/requestGenericIssuanceDeletePipeline.ts
@@ -0,0 +1,26 @@
+import urlJoin from "url-join";
+import { GenericIssuanceDeletePipelineResponseValue } from "../RequestTypes";
+import { APIResult } from "./apiResult";
+import { httpDeleteSimple } from "./makeRequest";
+
+/**
+ * Asks the server to fetch the pipeline definition corresponding to the
+ * given pipeline ID. Requires cookies, as this is part of generic issuance
+ * user authentication.
+ */
+export async function requestGenericIssuanceDeletePipeline(
+ zupassServerUrl: string,
+ pipelineId: string
+): Promise {
+ return httpDeleteSimple(
+ urlJoin(zupassServerUrl, `/generic-issuance/api/pipelines/${pipelineId}`),
+ async () => ({
+ value: undefined,
+ success: true
+ }),
+ true
+ );
+}
+
+export type GenericIssuanceDeletePipelineResponse =
+ APIResult;
diff --git a/packages/lib/passport-interface/src/api/requestGenericIssuanceGetAllUserPipelines.ts b/packages/lib/passport-interface/src/api/requestGenericIssuanceGetAllUserPipelines.ts
new file mode 100644
index 0000000000..58d802dd9d
--- /dev/null
+++ b/packages/lib/passport-interface/src/api/requestGenericIssuanceGetAllUserPipelines.ts
@@ -0,0 +1,26 @@
+import urlJoin from "url-join";
+import { GenericIssuanceGetAllUserPipelinesResponseValue } from "../RequestTypes";
+import { APIResult } from "./apiResult";
+import { httpGetSimple } from "./makeRequest";
+
+/**
+ * Asks the server to fetch the pipeline definition corresponding to the
+ * given pipeline ID. Requires cookies, as this is part of generic issuance
+ * user authentication.
+ */
+export async function requestGenericIssuanceGetAllUserPipelines(
+ zupassServerUrl: string
+): Promise {
+ return httpGetSimple(
+ urlJoin(zupassServerUrl, `/generic-issuance/api/pipelines`),
+ async (resText) => ({
+ value: JSON.parse(resText),
+ success: true
+ }),
+ undefined,
+ true
+ );
+}
+
+export type GenericIssuanceGetAllUserPipelinesResponse =
+ APIResult;
diff --git a/packages/lib/passport-interface/src/api/requestGenericIssuanceGetPipeline.ts b/packages/lib/passport-interface/src/api/requestGenericIssuanceGetPipeline.ts
new file mode 100644
index 0000000000..2b4eb7243e
--- /dev/null
+++ b/packages/lib/passport-interface/src/api/requestGenericIssuanceGetPipeline.ts
@@ -0,0 +1,27 @@
+import urlJoin from "url-join";
+import { GenericIssuanceGetPipelineResponseValue } from "../RequestTypes";
+import { APIResult } from "./apiResult";
+import { httpGetSimple } from "./makeRequest";
+
+/**
+ * Asks the server to fetch the pipeline definition corresponding to the
+ * given pipeline ID. Requires cookies, as this is part of generic issuance
+ * user authentication.
+ */
+export async function requestGenericIssuanceGetPipeline(
+ zupassServerUrl: string,
+ pipelineId: string
+): Promise {
+ return httpGetSimple(
+ urlJoin(zupassServerUrl, `/generic-issuance/api/pipelines/${pipelineId}`),
+ async (resText) => ({
+ value: JSON.parse(resText),
+ success: true
+ }),
+ undefined,
+ true
+ );
+}
+
+export type GenericIssuanceGetPipelineResponse =
+ APIResult;
diff --git a/packages/lib/passport-interface/src/api/requestGenericIssuanceUpsertPipeline.ts b/packages/lib/passport-interface/src/api/requestGenericIssuanceUpsertPipeline.ts
new file mode 100644
index 0000000000..3a10a945a9
--- /dev/null
+++ b/packages/lib/passport-interface/src/api/requestGenericIssuanceUpsertPipeline.ts
@@ -0,0 +1,30 @@
+import urlJoin from "url-join";
+import {
+ GenericIssuanceUpsertPipelineRequest,
+ GenericIssuanceUpsertPipelineResponseValue
+} from "../RequestTypes";
+import { APIResult } from "./apiResult";
+import { httpPutSimple } from "./makeRequest";
+
+/**
+ * Asks the server to fetch the pipeline definition corresponding to the
+ * given pipeline ID. Requires cookies, as this is part of generic issuance
+ * user authentication.
+ */
+export async function requestGenericIssuanceUpsertPipeline(
+ zupassServerUrl: string,
+ pipeline: GenericIssuanceUpsertPipelineRequest
+): Promise {
+ return httpPutSimple(
+ urlJoin(zupassServerUrl, `/generic-issuance/api/pipelines`),
+ async (resText) => ({
+ value: JSON.parse(resText),
+ success: true
+ }),
+ pipeline,
+ true
+ );
+}
+
+export type GenericIssuanceUpsertPipelineResponse =
+ APIResult;
diff --git a/packages/lib/passport-interface/src/genericIssuance.ts b/packages/lib/passport-interface/src/genericIssuance.ts
new file mode 100644
index 0000000000..e20381deb3
--- /dev/null
+++ b/packages/lib/passport-interface/src/genericIssuance.ts
@@ -0,0 +1,195 @@
+// TODO: Add shared pipeline types here
+
+import { z } from "zod";
+
+/**
+ * Each new {@link Pipeline} type needs a corresponding entry in thie enum.
+ */
+export enum PipelineType {
+ Lemonade = "Lemonade",
+ Pretix = "Pretix"
+}
+
+const BasePipelineDefinitionSchema = z.object({
+ id: z.string().uuid(),
+ ownerUserId: z.string().uuid(),
+ editorUserIds: z.array(z.string().uuid())
+});
+
+/**
+ * A pipeline definition is owned by the user who set it up. It's the
+ * persisted representation of a pipeline on our backend. When a user
+ * sets up a pipeline via the generic issuance UI, they are creating one
+ * of these over a series of configuration steps - choosing which data
+ * source to use, uploading an API key, selecting which data to load, etc.
+ */
+export type BasePipelineDefinition = z.infer<
+ typeof BasePipelineDefinitionSchema
+>;
+
+const LemonadePipelineTicketTierConfigSchema = z.object({
+ /**
+ * The ID of this ticket tier on the Lemonade end.
+ */
+ externalId: z.string(),
+ /**
+ * The UUID of this ticket tier used in {@link EdDSATicketPCD}.
+ */
+ genericIssuanceProductId: z.string().uuid(),
+ /**
+ * Whether this ticket tier is allowed to check other tickets in or not.
+ */
+ isSuperUser: z.boolean()
+});
+
+/**
+ * Generic Issuance-specific ticket tier configuration - roughly corresponds to a
+ * 'Product' in Pretix-land.
+ */
+export type LemonadePipelineTicketTierConfig = z.infer<
+ typeof LemonadePipelineTicketTierConfigSchema
+>;
+
+const LemonadePipelineEventConfigSchema = z.object({
+ /**
+ * The ID of this event on the Lemonade end.
+ */
+ externalId: z.string(),
+ /**
+ * Display name.
+ */
+ name: z.string(),
+ /**
+ * The UUID of this event used for {@link EdDSATicketPCD}.
+ */
+ genericIssuanceEventId: z.string().uuid(),
+ /**
+ * Roughly translates to Products in {@link EdDSATicketPCD}.
+ */
+ ticketTiers: z.array(LemonadePipelineTicketTierConfigSchema)
+});
+
+/**
+ * Generic Issuance-specific event configuration. Should roughly match up to the
+ * types defined above - {@link LemonadeTicket}, {@link LemonadeEvent}, and
+ * {@link LemonadeTicketTier}.
+ */
+export type LemonadePipelineEventConfig = z.infer<
+ typeof LemonadePipelineEventConfigSchema
+>;
+
+const LemonadePipelineOptionsSchema = z.object({
+ /**
+ * Configured by the user when setting up Lemonade as a data source.
+ */
+ lemonadeApiKey: z.string(),
+ events: z.array(LemonadePipelineEventConfigSchema)
+});
+
+export type LemonadePipelineOptions = z.infer<
+ typeof LemonadePipelineOptionsSchema
+>;
+
+const LemonadePipelineDefinitionSchema = BasePipelineDefinitionSchema.extend({
+ type: z.literal(PipelineType.Lemonade),
+ options: LemonadePipelineOptionsSchema
+});
+
+/**
+ * A {@link LemonadePipelineDefinition} is a pipeline that has finished being
+ * set up that configures the generic issuance service to load data on behalf
+ * of a particular user from Lemonade and issue tickets for it.
+ */
+export type LemonadePipelineDefinition = z.infer<
+ typeof LemonadePipelineDefinitionSchema
+>;
+
+const PretixProductConfigSchema = z.object({
+ /**
+ * Pretix's item ID
+ */
+ externalId: z.string(),
+ /**
+ * Our UUID
+ */
+ genericIssuanceId: z.string().uuid(),
+ /**
+ * Display name
+ */
+ name: z.string(),
+ /**
+ * Is a user with this product a "superuser"?
+ * Superusers are able to check tickets in to events.
+ */
+ isSuperUser: z.boolean()
+});
+
+/**
+ * Configuration for specific products available for the event. Does not need
+ * to include all products available in Pretix, but any product listed here
+ * must be available in Pretix.
+ */
+export type PretixProductConfig = z.infer;
+
+const PretixEventConfigSchema = z.object({
+ /**
+ * Pretix's event ID
+ */
+ externalId: z.string(),
+ /**
+ * Our UUID
+ */
+ genericIssuanceId: z.string().uuid(),
+ /**
+ * Display name for the event
+ */
+ name: z.string(),
+ products: z.array(PretixProductConfigSchema)
+});
+
+/**
+ * Configuration for a specific event, which is managed under the organizer's
+ * Pretix account.
+ */
+export type PretixEventConfig = z.infer;
+
+const PretixPipelineOptionsSchema = z.object({
+ /**
+ * This object represents a configuration from which the server can instantiate
+ * a functioning {@link PretixPipeline}. Partially specified by the user.
+ */
+ pretixAPIKey: z.string(),
+ pretixOrgUrl: z.string(),
+ events: z.array(PretixEventConfigSchema)
+});
+
+export type PretixPipelineOptions = z.infer;
+
+const PretixPipelineDefinitionSchema = BasePipelineDefinitionSchema.extend({
+ type: z.literal(PipelineType.Pretix),
+ options: PretixPipelineOptionsSchema
+});
+
+/**
+ * Similar to {@link LemonadePipelineDefinition} but for Pretix-based Pipelines.
+ */
+export type PretixPipelineDefinition = z.infer<
+ typeof PretixPipelineDefinitionSchema
+>;
+
+/**
+ * This item is exported so that we can use it for validation on generic issuance server.
+ */
+export const PipelineDefinitionSchema = z.union([
+ LemonadePipelineDefinitionSchema,
+ PretixPipelineDefinitionSchema
+]);
+
+/**
+ * Any new pipeline definitions need to be added to this type declaration. Note
+ * that the way I've set it up a {@link Pipeline} appears to only be able to have
+ * one data source. However, that is not the case. In the future, if needed, it
+ * would be possible to create Pipelines that load from an arbitrary quantity
+ * of data sources.
+ */
+export type PipelineDefinition = z.infer;
diff --git a/packages/lib/passport-interface/src/index.ts b/packages/lib/passport-interface/src/index.ts
index 83c3f40532..2219611322 100644
--- a/packages/lib/passport-interface/src/index.ts
+++ b/packages/lib/passport-interface/src/index.ts
@@ -34,6 +34,10 @@ export * from "./api/requestFrogCryptoUpdateFeeds";
export * from "./api/requestFrogCryptoUpdateFrogs";
export * from "./api/requestFrogCryptoUpdateTelegramSharing";
export * from "./api/requestGenericIssuanceCheckIn";
+export * from "./api/requestGenericIssuanceDeletePipeline";
+export * from "./api/requestGenericIssuanceGetAllUserPipelines";
+export * from "./api/requestGenericIssuanceGetPipeline";
+export * from "./api/requestGenericIssuanceUpsertPipeline";
export * from "./api/requestIssuanceServiceEnabled";
export * from "./api/requestKnownTickets";
export * from "./api/requestListFeeds";
@@ -54,5 +58,6 @@ export * from "./api/requestVerifyTicket";
export * from "./api/requestVerifyTicketById";
export * from "./api/requestVerifyToken";
export * from "./edgecity";
+export * from "./genericIssuance";
export * from "./zuconnect";
export * from "./zuzalu";
diff --git a/yarn.lock b/yarn.lock
index 42d74b1ae4..9341cfd1bf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5016,6 +5016,11 @@
resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-4.0.3.tgz#09ede6753b846a274301b9bd3a6ed117050daecd"
integrity sha512-3l1qMm3wqO0iyC5gkADzT95UVW7C/XXcdvUcShOideKF0ddgVRErEQQJXBd2kvQm+aSgqhBGHGB38TgMeT57Ww==
+"@types/use-sync-external-store@^0.0.3":
+ version "0.0.3"
+ resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
+ integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
+
"@types/uuid@^9.0.0", "@types/uuid@^9.0.2":
version "9.0.3"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.3.tgz#6cdd939b4316b4f81625de9f06028d848c4a1533"
@@ -14193,6 +14198,14 @@ react-qr-reader@^3.0.0-beta-1:
"@zxing/library" "^0.18.3"
rollup "^2.67.2"
+react-redux@^9.1.0:
+ version "9.1.0"
+ resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.1.0.tgz#46a46d4cfed4e534ce5452bb39ba18e1d98a8197"
+ integrity sha512-6qoDzIO+gbrza8h3hjMA9aq4nwVFCKFtY2iLxCtVT38Swyy2C/dJCGBXHeHLtx6qlg/8qzc2MrhOeduf5K32wQ==
+ dependencies:
+ "@types/use-sync-external-store" "^0.0.3"
+ use-sync-external-store "^1.0.0"
+
react-responsive-modal@^6.4.2:
version "6.4.2"
resolved "https://registry.yarnpkg.com/react-responsive-modal/-/react-responsive-modal-6.4.2.tgz#666b5c35b232cec617981006c9fe59414531a5a0"
@@ -16873,6 +16886,11 @@ use-isomorphic-layout-effect@^1.1.2:
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
+use-sync-external-store@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
+ integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
+
utf-8-validate@^5.0.2, utf-8-validate@^5.0.5:
version "5.0.10"
resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-5.0.10.tgz#d7d10ea39318171ca982718b6b96a8d2442571a2"