Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"author": "",
"license": "MIT",
"dependencies": {
"express": "^5.1.0",
"@partly/integrations-client": "0.1.1-alpha"
"@partly/integrations-client": "0.1.1-alpha",
"dotenv": "^17.2.3",
"express": "^5.1.0"
}
}
299 changes: 218 additions & 81 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,96 +1,233 @@
import path from 'path';
import express from 'express';
import crypto from 'crypto';
import { client as PartlyClient } from '@partly/integrations-client';
import crypto from "crypto";
import express from "express";
import path from "path";
import 'dotenv/config'

// Server configuration
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || "0.0.0.0";
const PARTLY_BASE_URL = process.env.PARTLY_BASE_URL || "https://app.partly.com";
const PARTLY_API_BASE_URL = process.env.PARTLY_API_BASE_URL ||"https://integrations.partly.com";

// Contact partly to set up a client ID, secret and authorization URL
const authorizationUrl = "http://localhost:3000/partly/install.complete"; // Authorization URL must be exactly as given to Partly during client ID / Secret setup
const scopes = "repairer"; // At this stage, must be either ["supplier"] or ["repairer"]
const clientId = "SAMPLE_INTEGRATION";
const clientSecret = 'REDACTED'; // Can be provided on request
// Integration configuration
//
// Please contact partly to set up a client ID, client secret, and to configure
// your redirect URL
//
const REDIRECT_URL = `http://${HOST}:${PORT}/partly/install.complete`;
const CLIENT_ID = process.env.CLIENT_ID || "SAMPLE_INTEGRATION";
const CLIENT_SECRET = process.env.CLIENT_SECRET || "<REDACTED>"; // Provided on request
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "<REDACTED>"; // Provided on request
const SCOPES = "repairer"; // Either ["supplier"] or ["repairer"]

if (CLIENT_SECRET === "<REDACTED>") {
console.warn(
"WARNING: Please set your CLIENT_SECRET in src/server.ts before running the sample."
);
process.exit(1);
}

if (WEBHOOK_SECRET === "<REDACTED>") {
console.warn(
"WARNING: Please set your WEBHOOK_SECRET in src/server.ts before running the sample."
);
process.exit(1);
}

// In-memory example storage for installations & state values
const INSTALLATIONS: { [integrationId: string]: string } = {};
const STATE_CACHE: { [key: string]: number } = {};

express()
.use(express.json())

/**
* Index route: Displays a HTML page with an install button.
*/
.get("/", (req, res) => {
res.sendFile(path.join(__dirname, "../public/index.html"));
})

/**
* Installation Step 1:
*
* - User initiates installation by clicking the install button (see
* public/index.html.)
* - We generate a random challenge that Partly will return to validate the
* request came from this integration, referred to as "state."
* - We redirect the web client to Partly's integrations authorization page where
* the user is prompted to approve the installationn.
*/
.get("/partly/install.start", (req, res) => {
const state = generateState();

// Send web client to Partly with client ID, state, redirect URL, and scopes
const params = new URLSearchParams({
client_id: CLIENT_ID,
state: state,
redirect_url: REDIRECT_URL,
scopes: SCOPES,
});

res.redirect(`${PARTLY_BASE_URL}/auth/integration/authorize?${params}`);
})

/**
* Installation Step 2:
*
* - Partly will redirect to this URL after the user has authorized the
* integration.
* - The state and access code query parameters will be provided by Partly.
* - The state parameter must be validated to ensure the request is legitimate.
*/
.get("/partly/install.complete", async (req, res) => {
const state = req.query.state?.toString() ?? "";
const accessCode = req.query.code?.toString() ?? "";

// validate state parameter
if (!validateStateOnce(state)) {
console.error("Invalid state parameter:", state);
res.status(403).send("Forbidden: invalid state parameter");
return;
}

// complete installation by exchanging the access code for an API key
// Please refer to: https://integrations.partly.com/api/docs/redoc
const response = await fetch(
`${PARTLY_API_BASE_URL}/api/2026-01/integrations.insert`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
client_id: CLIENT_ID,
access_code: accessCode,
client_secret:
// base64 encode the client secret before sending
Buffer.from(CLIENT_SECRET).toString("base64"),
}),
}
);

// successful response contains api_key and integration_id
if (response.ok) {
const body = (await response.json()) as {
api_key: string;
integration_id: string;
};

// installation successful! store the api_key and integration_id securely
// for future use
INSTALLATIONS[body.integration_id] = body.api_key;

return res.status(200).send({
message: "Installation successful",
});
}

// installation failed
// Please refer to Partly's API documentation for error response details
return res
.status(400)
.send(
`Installation failed: ${(await response.text()) || response.status}`
);
})

/**
* Receive Partly Webhooks:
*
* Webhook payloads are sent as signed JSON blobs. The signature is provided
* in the `partly-hmac-sha256` header. The HMAC signature is calculated using
* the shared WEBHOOK_SECRET.
*/
.post("/partly-webhook.accept", async (req, res) => {
const partlyHMACSignature = req.headers["partly-hmac-sha256"];

if (typeof partlyHMACSignature !== "string") {
return res.status(400).send("Missing signature header");
}

// Validate HMAC signature
const calculatedHmacDigest = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest("base64");
const hmacValid = crypto.timingSafeEqual(
Buffer.from(calculatedHmacDigest, "base64"),
Buffer.from(partlyHMACSignature, "base64")
);

if (!hmacValid) {
return res.status(401).send("HMAC validation failed");
}

const timestamp = req.body.timestamp;

if (!timestamp) {
return res.status(400).send("Missing timestamp field");
}

const ts = new Date(timestamp).getTime();
const now = new Date().getTime();

// Validate timestamp is within tolerance to prevent replay attacks
const TOLERANCE = 5 * 60 * 1000; // 5 minutes
if (Math.abs(now - ts) > TOLERANCE) {
return res.status(401).send("Timestamp is invalid");
}

return res.status(200).send({
message: "Webhook received successfully",
});
})

// Start server
.listen(PORT, HOST, () => {
console.log(`Server running at http://${HOST}:${PORT}`);
});

//------------------------------------------------------------------------------
// Supporting Functions
//------------------------------------------------------------------------------

// Simple in-memory cache for state values
const app = express();
const port = 3000;
const stateCache: { [key: string]: number } = {};
const STATE_TTL_MS = 15 * 60 * 1000; // 15 minute expiry
const partlyClient = PartlyClient("https://integrations.partly.com");

// Base route - Displays a HTML page with an install button
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});

/**
* Installation Start - Called when clicked by the install button
* - Generates a random challenge that Partly will return to validate the request came from this integration
* - Redirects the web client to Partlys integrations authorization page to approve the installation
* Generates a random state string and caches it with a timestamp.
*/
app.get('/partly/install.start', (req, res) => {
function generateState(): string {
// Generate Challenge
const state = crypto.randomBytes(16).toString('hex');
stateCache[state] = Date.now();
// Implementation detail: Cache TTL eviction
for (const key in stateCache) {
if (Date.now() - stateCache[key] > STATE_TTL_MS) {
delete stateCache[key];
const state = crypto.randomBytes(16).toString("hex");
STATE_CACHE[state] = Date.now();

// evict old states (implementation detail)
for (const key in STATE_CACHE) {
if (Date.now() - STATE_CACHE[key] > STATE_TTL_MS) {
delete STATE_CACHE[key];
}
}

// Send web client to Partly with clientID, state (challenge), authorizationUrl, scopes
const params = new URLSearchParams({
client_id: clientId,
state,
authorization_url: authorizationUrl,
scopes,
});

const redirectUrl = `https://app.partly.com/auth/integrations/authorize?${params.toString()}`;
res.redirect(redirectUrl);
});
return state;
}

/**
* Installation Step 2 - Partly will redirect to this URL after the user has authorized the integration
* - It will check that the request came from the integration by checking the state value
* Validates the state parameter and removes it from the cache.
*/
app.get('/partly/install.complete', async (req, res) => {
// These will both exist if the request comes from Partly, this however cannot be guaranteed
const state: string = req.query.state?.toString() ?? '';
const accessCode = req.query.code?.toString() ?? '';

if (!stateCache[state]) {
// Security check, if the state param isn't one the server is aware of then this request cannot be trusted
// You can also add checks for account consistency, ie the user that made the request is the one that is completing the installation
res.status(403).send("Forbidden");
return;
}

// Remove used state to prevent replay attacks
delete stateCache[state];

// Docs for this endpoint can be seen here: https://integrations.partly.com/api/docs/redoc
// Base64 encode the client secret before sending
const encodedClientSecret = Buffer.from(clientSecret).toString('base64');
const installationResult = await partlyClient.integrations.insert({
client_id: clientId,
access_code: accessCode,
client_secret: encodedClientSecret
}, {});

if (installationResult.is_err()) {
// Could check via status 2xx / 4xx / 5xx
// Partly also supplies structured error data in the body
res.status(400).send("Bad request");
return;
};

// Returns a JSON matching the schema below
// { api_key: string, integration_id: uuid }
res.status(200).send(installationResult.unwrap_ok());
});

app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
function validateStateOnce(state: string): boolean {
const timestamp = STATE_CACHE[state];

// Check if state exists
if (!timestamp) {
return false;
}

// Remove state to ensure one-time use
delete STATE_CACHE[state]; // Remove expired state

// Check if state is still valid
return timestamp + STATE_TTL_MS > Date.now();
}