From 7c925c04fd430f54c73517f706a6543211a6942a Mon Sep 17 00:00:00 2001 From: naftis Date: Thu, 23 Jan 2025 16:22:17 +0200 Subject: [PATCH 1/4] feat: initial informant verification --- docs/playground.ipynb | 18 +++--- package.json | 2 +- packages/country-config/package.json | 2 +- packages/country-config/src/events.ts | 48 ++++++++++++++-- packages/esignet-mock/package.json | 2 +- packages/mosip-api/package.json | 2 +- packages/mosip-api/src/index.ts | 20 +++++-- packages/mosip-api/src/mosip-api.ts | 5 ++ packages/mosip-api/src/opencrvs-api.ts | 16 ++++++ .../event-registration.ts} | 2 +- packages/mosip-api/src/routes/event-review.ts | 39 +++++++++++++ .../src/{webhooks => routes}/mosip.ts | 0 packages/mosip-api/src/types/fhir.ts | 55 ++++++++++++++++++- packages/mosip-mock/package.json | 2 +- 14 files changed, 189 insertions(+), 24 deletions(-) rename packages/mosip-api/src/{webhooks/opencrvs.ts => routes/event-registration.ts} (98%) create mode 100644 packages/mosip-api/src/routes/event-review.ts rename packages/mosip-api/src/{webhooks => routes}/mosip.ts (100%) diff --git a/docs/playground.ipynb b/docs/playground.ipynb index 2147f7e..f517cb1 100644 --- a/docs/playground.ipynb +++ b/docs/playground.ipynb @@ -9,7 +9,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -21,7 +21,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 8, "metadata": {}, "outputs": [ { @@ -35,8 +35,8 @@ "source": [ "import requests\n", "\n", - "url = \"http://localhost:2024/webhooks/opencrvs\"\n", - "token = \"your_token_here\"\n", + "url = \"http://localhost:2024/events/registration\"\n", + "token = \"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6WyJyZWdpc3RlciIsInBlcmZvcm1hbmNlIiwiY2VydGlmeSIsImRlbW8iXSwiaWF0IjoxNzM3NDcwNjI1LCJleHAiOjE3MzgwNzU0MjUsImF1ZCI6WyJvcGVuY3J2czphdXRoLXVzZXIiLCJvcGVuY3J2czp1c2VyLW1nbnQtdXNlciIsIm9wZW5jcnZzOmhlYXJ0aC11c2VyIiwib3BlbmNydnM6Z2F0ZXdheS11c2VyIiwib3BlbmNydnM6bm90aWZpY2F0aW9uLXVzZXIiLCJvcGVuY3J2czp3b3JrZmxvdy11c2VyIiwib3BlbmNydnM6c2VhcmNoLXVzZXIiLCJvcGVuY3J2czptZXRyaWNzLXVzZXIiLCJvcGVuY3J2czpjb3VudHJ5Y29uZmlnLXVzZXIiLCJvcGVuY3J2czp3ZWJob29rcy11c2VyIiwib3BlbmNydnM6Y29uZmlnLXVzZXIiLCJvcGVuY3J2czpkb2N1bWVudHMtdXNlciJdLCJpc3MiOiJvcGVuY3J2czphdXRoLXNlcnZpY2UiLCJzdWIiOiI2NzRkZTAzMGY4YzBhMWMxMmVmODBjODcifQ.BViXNILaE8aEKEXdb46gWGuuIarwxAMCY1hKM7lO6X3p7vcM7VfarPu36usM3Ca0AygOVIYwxZ5wEsJwAng1F10FSYBnu1G8vlk1nB99vqZa5_9Q0p-2lyfHkjFEOsusFjU1z7uTZ53VYJ_EsLwv6ClSF9slr4SxUL5486xC8mG9MuJpvKyGCPt9yPvfUyEX41PImrReMHJLgnE4S74bQW-B8CH2gi_CnZBGmYewljXF1Wf8AQgHqXfpTMO8M7mP947x3CMgdZVaRkd9mycsoPQCKVyH_P8kCjobwZxgPmmMAr9yfXfWGCVJvxQSJVNlpzcPpR9uygdl14IGn_eiQA\"\n", "headers = {\"Authorization\": f\"Bearer {token}\"}\n", "response = requests.post(url, json=event, headers=headers)\n", "print(response.status_code)\n" @@ -51,7 +51,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 9, "metadata": {}, "outputs": [], "source": [ @@ -63,7 +63,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 10, "metadata": {}, "outputs": [ { @@ -77,8 +77,8 @@ "source": [ "import requests\n", "\n", - "url = \"http://localhost:2024/webhooks/opencrvs\"\n", - "token = \"your_token_here\"\n", + "url = \"http://localhost:2024/events/registration\"\n", + "token = \"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6WyJyZWdpc3RlciIsInBlcmZvcm1hbmNlIiwiY2VydGlmeSIsImRlbW8iXSwiaWF0IjoxNzM3NDcwNjI1LCJleHAiOjE3MzgwNzU0MjUsImF1ZCI6WyJvcGVuY3J2czphdXRoLXVzZXIiLCJvcGVuY3J2czp1c2VyLW1nbnQtdXNlciIsIm9wZW5jcnZzOmhlYXJ0aC11c2VyIiwib3BlbmNydnM6Z2F0ZXdheS11c2VyIiwib3BlbmNydnM6bm90aWZpY2F0aW9uLXVzZXIiLCJvcGVuY3J2czp3b3JrZmxvdy11c2VyIiwib3BlbmNydnM6c2VhcmNoLXVzZXIiLCJvcGVuY3J2czptZXRyaWNzLXVzZXIiLCJvcGVuY3J2czpjb3VudHJ5Y29uZmlnLXVzZXIiLCJvcGVuY3J2czp3ZWJob29rcy11c2VyIiwib3BlbmNydnM6Y29uZmlnLXVzZXIiLCJvcGVuY3J2czpkb2N1bWVudHMtdXNlciJdLCJpc3MiOiJvcGVuY3J2czphdXRoLXNlcnZpY2UiLCJzdWIiOiI2NzRkZTAzMGY4YzBhMWMxMmVmODBjODcifQ.BViXNILaE8aEKEXdb46gWGuuIarwxAMCY1hKM7lO6X3p7vcM7VfarPu36usM3Ca0AygOVIYwxZ5wEsJwAng1F10FSYBnu1G8vlk1nB99vqZa5_9Q0p-2lyfHkjFEOsusFjU1z7uTZ53VYJ_EsLwv6ClSF9slr4SxUL5486xC8mG9MuJpvKyGCPt9yPvfUyEX41PImrReMHJLgnE4S74bQW-B8CH2gi_CnZBGmYewljXF1Wf8AQgHqXfpTMO8M7mP947x3CMgdZVaRkd9mycsoPQCKVyH_P8kCjobwZxgPmmMAr9yfXfWGCVJvxQSJVNlpzcPpR9uygdl14IGn_eiQA\"\n", "headers = {\"Authorization\": f\"Bearer {token}\"}\n", "response = requests.post(url, json=event, headers=headers)\n", "print(response.status_code)" @@ -101,7 +101,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.10" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/package.json b/package.json index 221d347..e018544 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencrvs/mosip", - "version": "1.7.0-alpha.11", + "version": "1.7.0-alpha.12", "license": "MPL-2.0", "private": true, "packageManager": "yarn@1.22.13", diff --git a/packages/country-config/package.json b/packages/country-config/package.json index cc33f5c..c4ce6fd 100644 --- a/packages/country-config/package.json +++ b/packages/country-config/package.json @@ -1,6 +1,6 @@ { "name": "@opencrvs/mosip", - "version": "1.7.0-alpha.11", + "version": "1.7.0-alpha.12", "license": "MPL-2.0", "main": "./build/index.js", "exports": { diff --git a/packages/country-config/src/events.ts b/packages/country-config/src/events.ts index 01bb77f..a56b5ae 100644 --- a/packages/country-config/src/events.ts +++ b/packages/country-config/src/events.ts @@ -1,20 +1,60 @@ import type * as Hapi from "@hapi/hapi"; import fetch from "node-fetch"; +/** + * Replaces event registration handler in country config + */ export const mosipRegistrationHandler = ({ url }: { url: string }) => (async (request: Hapi.Request, h: Hapi.ResponseToolkit) => { - const OPENCRVS_MOSIP_GATEWAY_URL = new URL("./webhooks/opencrvs", url); + const MOSIP_API_REGISTRATION_EVENT_URL = new URL( + "./events/registration", + url, + ); - const response = await fetch(OPENCRVS_MOSIP_GATEWAY_URL, { + const response = await fetch(MOSIP_API_REGISTRATION_EVENT_URL, { method: "POST", body: JSON.stringify(request.payload), - headers: request.headers, + headers: { + ...request.headers, + "content-type": "application/json", + }, }); if (!response.ok) { return h .response({ - error: "OpenCRVS-MOSIP gateway did not return a 200", + error: "OpenCRVS-MOSIP event registration route did not return a 200", + response: await response.text(), + }) + .code(500); + } + + return h.response({ success: true }).code(200); + }) satisfies Hapi.ServerRoute["handler"]; + +/** + * Replaces `/events/{event}/actions/sent-notification-for-review` handler in country config + */ +export const mosipRegistrationForReviewHandler = ({ url }: { url: string }) => + (async (request: Hapi.Request, h: Hapi.ResponseToolkit) => { + // Corresponds to `packages/mosip-api` /events/review -route + const MOSIP_API_REVIEW_EVENT_URL = new URL("./events/review", url); + + console.info(request.headers); + + const response = await fetch(MOSIP_API_REVIEW_EVENT_URL, { + method: "POST", + body: JSON.stringify(request.payload), + headers: { + ...request.headers, + "content-type": "application/json", + }, + }); + + if (!response.ok) { + return h + .response({ + error: "OpenCRVS-MOSIP event review route did not return a 200", response: await response.text(), }) .code(500); diff --git a/packages/esignet-mock/package.json b/packages/esignet-mock/package.json index b444b90..d2b8fce 100644 --- a/packages/esignet-mock/package.json +++ b/packages/esignet-mock/package.json @@ -1,7 +1,7 @@ { "name": "@opencrvs/esignet-mock", "license": "MPL-2.0", - "version": "1.7.0-alpha.11", + "version": "1.7.0-alpha.12", "main": "index.js", "scripts": { "dev": "NODE_ENV=development tsx watch src/index.ts", diff --git a/packages/mosip-api/package.json b/packages/mosip-api/package.json index 87d7ac1..a3572e2 100644 --- a/packages/mosip-api/package.json +++ b/packages/mosip-api/package.json @@ -1,6 +1,6 @@ { "name": "@opencrvs/mosip-api", - "version": "1.7.0-alpha.11", + "version": "1.7.0-alpha.12", "license": "MPL-2.0", "scripts": { "dev": "NODE_ENV=development tsx watch src/index.ts", diff --git a/packages/mosip-api/src/index.ts b/packages/mosip-api/src/index.ts index cdd59e0..d9cac38 100644 --- a/packages/mosip-api/src/index.ts +++ b/packages/mosip-api/src/index.ts @@ -4,12 +4,16 @@ import { validatorCompiler, ZodTypeProvider, } from "fastify-type-provider-zod"; -import { mosipHandler, mosipNidSchema } from "./webhooks/mosip"; -import { opencrvsHandler, opencrvsRecordSchema } from "./webhooks/opencrvs"; +import { mosipHandler, mosipNidSchema } from "./routes/mosip"; +import { + registrationEventHandler, + opencrvsRecordSchema, +} from "./routes/event-registration"; import { env } from "./constants"; import * as openapi from "./openapi-documentation"; import { getOIDPUserInfo, OIDPUserInfoSchema } from "./esignet-api"; import formbody from "@fastify/formbody"; +import { reviewEventHandler } from "./routes/event-review"; const envToLogger = { development: { @@ -38,9 +42,17 @@ app.setErrorHandler((error, request, reply) => { app.after(() => { app.withTypeProvider().route({ - url: "/webhooks/opencrvs", + url: "/events/registration", + method: "POST", + handler: registrationEventHandler, + schema: { + body: opencrvsRecordSchema, + }, + }); + app.withTypeProvider().route({ + url: "/events/review", method: "POST", - handler: opencrvsHandler, + handler: reviewEventHandler, schema: { body: opencrvsRecordSchema, }, diff --git a/packages/mosip-api/src/mosip-api.ts b/packages/mosip-api/src/mosip-api.ts index 3dc185b..c53dccd 100644 --- a/packages/mosip-api/src/mosip-api.ts +++ b/packages/mosip-api/src/mosip-api.ts @@ -49,3 +49,8 @@ export const deactivateNid = async ({ nid }: { nid: string }) => { return response; }; + +export const verifyNid = async ({ nid: _nid }: { nid: string }) => { + // @TODO: Implement verification via mock MOSIP API & actual MOSIP API + return true; +}; diff --git a/packages/mosip-api/src/opencrvs-api.ts b/packages/mosip-api/src/opencrvs-api.ts index 5ecb51a..83447c1 100644 --- a/packages/mosip-api/src/opencrvs-api.ts +++ b/packages/mosip-api/src/opencrvs-api.ts @@ -129,3 +129,19 @@ export const upsertRegistrationIdentifier = ( }, headers, }); + +export const updateField = ( + id: string, + fieldId: string, + valueBoolean: boolean, + { headers }: { headers: Record }, +) => + post({ + query: /* GraphQL */ ` + mutation updateField($id: ID!, $details: UpdateFieldInput!) { + updateField(id: $id, details: $details) + } + `, + variables: { id, details: { fieldId, valueBoolean } }, + headers, + }); diff --git a/packages/mosip-api/src/webhooks/opencrvs.ts b/packages/mosip-api/src/routes/event-registration.ts similarity index 98% rename from packages/mosip-api/src/webhooks/opencrvs.ts rename to packages/mosip-api/src/routes/event-registration.ts index 0228c7f..f9fd202 100644 --- a/packages/mosip-api/src/webhooks/opencrvs.ts +++ b/packages/mosip-api/src/routes/event-registration.ts @@ -25,7 +25,7 @@ type OpenCRVSRequest = FastifyRequest<{ }>; /** Handles the calls coming from OpenCRVS countryconfig */ -export const opencrvsHandler = async ( +export const registrationEventHandler = async ( request: OpenCRVSRequest, reply: FastifyReply, ) => { diff --git a/packages/mosip-api/src/routes/event-review.ts b/packages/mosip-api/src/routes/event-review.ts new file mode 100644 index 0000000..1d50a56 --- /dev/null +++ b/packages/mosip-api/src/routes/event-review.ts @@ -0,0 +1,39 @@ +import { FastifyRequest, FastifyReply } from "fastify"; +import { getComposition, getInformantType } from "../types/fhir"; +import { updateField } from "../opencrvs-api"; + +type OpenCRVSRequest = FastifyRequest<{ + Body: fhir3.Bundle; +}>; + +export const reviewEventHandler = async ( + request: OpenCRVSRequest, + reply: FastifyReply, +) => { + const informantType = getInformantType(request.body); + const { id: eventId } = getComposition(request.body); + + if (!request.headers.authorization) { + return reply.code(401).send({ error: "Authorization header is missing" }); + } + + const token = request.headers.authorization.split(" ")[1]; + if (!token) { + return reply + .code(401) + .send({ error: "Token is missing in Authorization header" }); + } + + // Initial test of the verification, we will verify only other informants than mother and father + if (informantType !== "mother" && informantType !== "father") { + console.log("here"); + await updateField( + eventId, + `birth.informant.informant-view-group.verified`, + true, + { headers: { Authorization: `Bearer ${token}` } }, + ); + } + + return reply.code(200).send({ success: true }); +}; diff --git a/packages/mosip-api/src/webhooks/mosip.ts b/packages/mosip-api/src/routes/mosip.ts similarity index 100% rename from packages/mosip-api/src/webhooks/mosip.ts rename to packages/mosip-api/src/routes/mosip.ts diff --git a/packages/mosip-api/src/types/fhir.ts b/packages/mosip-api/src/types/fhir.ts index c55792c..0acc023 100644 --- a/packages/mosip-api/src/types/fhir.ts +++ b/packages/mosip-api/src/types/fhir.ts @@ -1,5 +1,5 @@ // Copypasted types from @opencrvs/commons -// In 1.8.0 we'll move importing these from @opencrvs/toolkit +// For this reason, here are shortcuts and `!` assertions, as we haven't copypasted ALL types from @opencrvs/commons declare const __nominal__type: unique symbol; export type Nominal = Type & { @@ -507,3 +507,56 @@ export const getDeceasedNid = (bundle: fhir3.Bundle) => { const deceased = findDeceasedEntry(composition, bundle); return getPatientNationalId(deceased as fhir3.Patient); }; + +export function findCompositionSection( + code: string, + composition: T, +) { + return composition.section!.find((section) => + section.code!.coding!.some((coding) => coding.code === code), + ); +} + +export function resourceIdentifierToUUID( + resourceIdentifier: ResourceIdentifier, +) { + const urlParts = resourceIdentifier.split("/"); + return urlParts[urlParts.length - 1] as UUID; +} + +export type URNReference = `urn:uuid:${UUID}`; + +export function isURNReference(id: string): id is URNReference { + return id.startsWith("urn:uuid:"); +} + +export function isSaved(resource: T) { + return resource.id !== undefined; +} + +export function findEntryFromBundle( + bundle: fhir3.Bundle, + reference: fhir3.Reference["reference"], +) { + return isURNReference(reference!) + ? bundle.entry!.find((entry) => entry.fullUrl === reference) + : bundle.entry!.find( + (entry) => + isSaved(entry.resource!) && + entry.resource!.id === + resourceIdentifierToUUID(reference as ResourceIdentifier), + ); +} + +export function getInformantType(record: fhir3.Bundle) { + const compositionSection = findCompositionSection( + "informant-details", + getComposition(record), + ); + if (!compositionSection) return undefined; + const personSectionEntry = compositionSection.entry![0]; + const personEntry = findEntryFromBundle(record, personSectionEntry.reference); + + return (personEntry?.resource as fhir3.RelatedPerson).relationship + ?.coding?.[0].code; +} diff --git a/packages/mosip-mock/package.json b/packages/mosip-mock/package.json index b68ce50..d60e2d4 100644 --- a/packages/mosip-mock/package.json +++ b/packages/mosip-mock/package.json @@ -1,6 +1,6 @@ { "name": "@opencrvs/mosip-mock", - "version": "1.7.0-alpha.11", + "version": "1.7.0-alpha.12", "license": "MPL-2.0", "scripts": { "dev": "NODE_ENV=development tsx watch src/index.ts", From 7957d1f390132e11e0bdef78f57aa0a93aa5689c Mon Sep 17 00:00:00 2001 From: Tameem Bin Haider Date: Wed, 29 Jan 2025 19:03:48 +0600 Subject: [PATCH 2/4] fix: change field value to be "verified" on success --- packages/mosip-api/src/opencrvs-api.ts | 4 ++-- packages/mosip-api/src/routes/event-review.ts | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/mosip-api/src/opencrvs-api.ts b/packages/mosip-api/src/opencrvs-api.ts index 83447c1..3e27946 100644 --- a/packages/mosip-api/src/opencrvs-api.ts +++ b/packages/mosip-api/src/opencrvs-api.ts @@ -133,7 +133,7 @@ export const upsertRegistrationIdentifier = ( export const updateField = ( id: string, fieldId: string, - valueBoolean: boolean, + valueString: string, { headers }: { headers: Record }, ) => post({ @@ -142,6 +142,6 @@ export const updateField = ( updateField(id: $id, details: $details) } `, - variables: { id, details: { fieldId, valueBoolean } }, + variables: { id, details: { fieldId, valueString } }, headers, }); diff --git a/packages/mosip-api/src/routes/event-review.ts b/packages/mosip-api/src/routes/event-review.ts index 1d50a56..647ea4f 100644 --- a/packages/mosip-api/src/routes/event-review.ts +++ b/packages/mosip-api/src/routes/event-review.ts @@ -26,11 +26,10 @@ export const reviewEventHandler = async ( // Initial test of the verification, we will verify only other informants than mother and father if (informantType !== "mother" && informantType !== "father") { - console.log("here"); await updateField( eventId, `birth.informant.informant-view-group.verified`, - true, + 'verified', { headers: { Authorization: `Bearer ${token}` } }, ); } From b84537086fa66e4660a9f69b080184a3dca3b1b2 Mon Sep 17 00:00:00 2001 From: Tameem Bin Haider Date: Wed, 29 Jan 2025 19:08:53 +0600 Subject: [PATCH 3/4] feat: add handler for approval action --- packages/country-config/src/events.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/country-config/src/events.ts b/packages/country-config/src/events.ts index a56b5ae..4bf5d50 100644 --- a/packages/country-config/src/events.ts +++ b/packages/country-config/src/events.ts @@ -62,3 +62,9 @@ export const mosipRegistrationForReviewHandler = ({ url }: { url: string }) => return h.response({ success: true }).code(200); }) satisfies Hapi.ServerRoute["handler"]; + +/** + * Replaces `/events/{event}/actions/sent-for-approval` handler in country config + * Currently the same as `/events/{event}/actions/sent-notification-for-review` + */ +export const mosipRegistrationForApprovalHandler = mosipRegistrationForReviewHandler From e77e7670a1987ef252ff316e83ca342c3935cabe Mon Sep 17 00:00:00 2001 From: Tameem Bin Haider Date: Wed, 29 Jan 2025 19:13:56 +0600 Subject: [PATCH 4/4] chore: bump up version --- package.json | 2 +- packages/country-config/package.json | 2 +- packages/esignet-mock/package.json | 2 +- packages/mosip-api/package.json | 2 +- packages/mosip-mock/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 0318215..76b67e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencrvs/mosip", - "version": "1.7.0-alpha.15", + "version": "1.7.0-alpha.16", "license": "MPL-2.0", "private": true, "packageManager": "yarn@1.22.13", diff --git a/packages/country-config/package.json b/packages/country-config/package.json index bd76fe9..1f5549d 100644 --- a/packages/country-config/package.json +++ b/packages/country-config/package.json @@ -1,6 +1,6 @@ { "name": "@opencrvs/mosip", - "version": "1.7.0-alpha.15", + "version": "1.7.0-alpha.16", "license": "MPL-2.0", "main": "./build/index.js", "exports": { diff --git a/packages/esignet-mock/package.json b/packages/esignet-mock/package.json index b6140f2..e91c8ee 100644 --- a/packages/esignet-mock/package.json +++ b/packages/esignet-mock/package.json @@ -1,7 +1,7 @@ { "name": "@opencrvs/esignet-mock", "license": "MPL-2.0", - "version": "1.7.0-alpha.15", + "version": "1.7.0-alpha.16", "main": "index.js", "scripts": { "dev": "NODE_ENV=development tsx watch src/index.ts", diff --git a/packages/mosip-api/package.json b/packages/mosip-api/package.json index 53bc7f5..f69c176 100644 --- a/packages/mosip-api/package.json +++ b/packages/mosip-api/package.json @@ -1,6 +1,6 @@ { "name": "@opencrvs/mosip-api", - "version": "1.7.0-alpha.15", + "version": "1.7.0-alpha.16", "license": "MPL-2.0", "scripts": { "dev": "NODE_ENV=development tsx watch src/index.ts", diff --git a/packages/mosip-mock/package.json b/packages/mosip-mock/package.json index 425fa20..54a610c 100644 --- a/packages/mosip-mock/package.json +++ b/packages/mosip-mock/package.json @@ -1,6 +1,6 @@ { "name": "@opencrvs/mosip-mock", - "version": "1.7.0-alpha.15", + "version": "1.7.0-alpha.16", "license": "MPL-2.0", "scripts": { "dev": "NODE_ENV=development tsx watch src/index.ts",