TWIN Supply Chain Docs
TutorialsRun seed scripts

Script source

Full source code for the TWIN supply-chain seed scripts.

consignmentsCreate

// Copyright 2026 IOTA Stiftung.
// SPDX-License-Identifier: Apache-2.0.
import {
	type ISupplyChainAppConsignment,
	supplyChainConsignmentPayload
} from "@twin.org/supply-chain-models";

import { getClient } from "./lib/client.js";

export default async function consignmentsCreate(): Promise<void> {
	const client = await getClient();
	const result = await client.consignmentCreate(createConsignmentPayload());
	console.log("Success:", JSON.stringify(result, null, 2));
}

export function createConsignmentPayload(): ISupplyChainAppConsignment {
	return {
		...supplyChainConsignmentPayload,
		identifier: `INT-${crypto.randomUUID().slice(0, 8).toUpperCase()}`
	};
}

consignmentsCreateWithEvents

// Copyright 2026 IOTA Stiftung.
// SPDX-License-Identifier: Apache-2.0.
import type { IJsonLdNodeObject } from "@twin.org/data-json-ld";
import { UneceContexts, UneceTypes } from "@twin.org/standards-unece";
import {
	supplyChainConsignmentPayload,
	supplyChainEventTypeCodes
} from "@twin.org/supply-chain-models";
import type { SupplyChainRestClient } from "@twin.org/supply-chain-rest-client";

import { getClient } from "./lib/client.js";

function mkEvent(
	typeCode: string,
	occurrenceDateTime: string,
	extra: Record<string, unknown> = {}
): IJsonLdNodeObject {
	return {
		"@context": UneceContexts.Context,
		type: UneceTypes.SupplyChainEvent,
		typeCode,
		occurrenceDateTime,
		...extra
	};
}

const location = (name: string, locationID: string) => ({
	type: UneceTypes.LogisticsLocation,
	name,
	locationID
});

const carrier = (name: string, eori: string, bookingId: string) => ({
	carrierAssignedId: bookingId,
	carrierParty: {
		type: UneceTypes.TradeParty,
		name,
		registeredId: {
			"@type": "https://ref.gs1.org/voc/OrganizationID_Type-EORI",
			"@value": eori
		}
	}
});

const transportMeans = (typeCode = "1") => ({
	transportMeans: { type: UneceTypes.TransportMeans, transportMeansTypeCode: typeCode }
});

// ─── Scenario definitions ────────────────────────────────────────────────────

interface Scenario {
	label: string;
	description: string;
	overrides: Partial<typeof supplyChainConsignmentPayload>;
	events: IJsonLdNodeObject[];
}

const SCENARIOS: Scenario[] = [
	// 1. Planned — only pre-notification received
	{
		label: "Planned for departure",
		description: "Consignment created, pre-notification sent. No despatch yet.",
		overrides: {},
		events: [mkEvent(supplyChainEventTypeCodes.PreNotification, "2026-05-20T08:00:00.000Z")]
	},

	// 2. Active — despatched, carrier booked
	{
		label: "Despatched with carrier",
		description: "Goods despatched and carrier booking confirmed.",
		overrides: {},
		events: [
			mkEvent(supplyChainEventTypeCodes.PreNotification, "2026-05-19T08:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.Despatched, "2026-05-20T09:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.CarrierBooking, "2026-05-20T11:00:00.000Z", {
				...carrier("Maersk Line", "GBMAERSK001", "MSK-20260520-001"),
				eventLocation: location("Port of Calais", "FRCDG")
			})
		]
	},

	// 3. Active — at location of exit, location operator matched
	{
		label: "At exit — operator matched",
		description: "Goods at exit location. Location operator has confirmed a system match.",
		overrides: {},
		events: [
			mkEvent(supplyChainEventTypeCodes.PreNotification, "2026-05-18T08:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.Despatched, "2026-05-19T09:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.CarrierBooking, "2026-05-19T11:00:00.000Z", {
				...carrier("CMA CGM", "FRCMA001", "CMA-20260519-042"),
				eventLocation: location("Port of Calais", "FRCDG")
			}),
			mkEvent(supplyChainEventTypeCodes.IssuedDocument, "2026-05-20T10:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.OnwardSharing, "2026-05-20T14:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.AtLocationOfExit, "2026-05-22T07:00:00.000Z", {
				eventLocation: location("Port of Calais", "FRCDG"),
				...transportMeans("1")
			}),
			mkEvent(supplyChainEventTypeCodes.LocationOperatorMatch, "2026-05-22T09:00:00.000Z", {
				eventLocation: location("Felixstowe", "GBFXT"),
				operatorName: "Marine Cargo Processing"
			})
		]
	},

	// 4. Active — crossed border, at entry
	{
		label: "In transit — at entry location",
		description: "Goods left exit location and arrived at entry location.",
		overrides: {},
		events: [
			mkEvent(supplyChainEventTypeCodes.PreNotification, "2026-05-15T08:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.Despatched, "2026-05-16T09:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.CarrierBooking, "2026-05-16T11:00:00.000Z", {
				...carrier("P&O Ferries", "GBPOF001", "POF-20260516-007"),
				eventLocation: location("Port of Calais", "FRCDG")
			}),
			mkEvent(supplyChainEventTypeCodes.AtLocationOfExit, "2026-05-18T07:00:00.000Z", {
				eventLocation: location("Port of Calais", "FRCDG"),
				...transportMeans("1")
			}),
			mkEvent(supplyChainEventTypeCodes.LocationOperatorMatch, "2026-05-18T09:00:00.000Z", {
				eventLocation: location("Port of Dover", "GBDVR"),
				operatorName: "Dover Port Authority"
			}),
			mkEvent(supplyChainEventTypeCodes.LeftLocationOfExit, "2026-05-18T12:00:00.000Z", {
				eventLocation: location("Port of Calais", "FRCDG")
			}),
			mkEvent(supplyChainEventTypeCodes.AtLocationOfEntry, "2026-05-18T14:00:00.000Z", {
				eventLocation: location("Port of Dover", "GBDVR"),
				...transportMeans("1")
			})
		]
	},

	// 5. Delivered — full journey completed
	{
		label: "Delivered — full journey",
		description: "Complete journey from pre-notification through to final destination.",
		overrides: {},
		events: [
			mkEvent(supplyChainEventTypeCodes.PreNotification, "2026-05-10T08:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.Despatched, "2026-05-11T09:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.CarrierBooking, "2026-05-11T11:00:00.000Z", {
				...carrier("DFDS Seaways", "GBDFD001", "DFDS-20260511-099"),
				eventLocation: location("Port of Calais", "FRCDG")
			}),
			mkEvent(supplyChainEventTypeCodes.IssuedDocument, "2026-05-12T10:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.OnwardSharing, "2026-05-12T14:00:00.000Z"),
			mkEvent(supplyChainEventTypeCodes.AtLocationOfExit, "2026-05-13T07:00:00.000Z", {
				eventLocation: location("Port of Calais", "FRCDG"),
				...transportMeans("1")
			}),
			mkEvent(supplyChainEventTypeCodes.LocationOperatorMatch, "2026-05-13T09:00:00.000Z", {
				eventLocation: location("Port of Dover", "GBDVR"),
				operatorName: "Dover Harbour Board"
			}),
			mkEvent(supplyChainEventTypeCodes.LeftLocationOfExit, "2026-05-13T12:00:00.000Z", {
				eventLocation: location("Port of Calais", "FRCDG")
			}),
			mkEvent(supplyChainEventTypeCodes.AtLocationOfEntry, "2026-05-13T14:00:00.000Z", {
				eventLocation: location("Port of Dover", "GBDVR"),
				...transportMeans("1")
			}),
			mkEvent(supplyChainEventTypeCodes.LeftLocationOfEntry, "2026-05-13T17:00:00.000Z", {
				eventLocation: location("Port of Dover", "GBDVR")
			}),
			mkEvent(supplyChainEventTypeCodes.ArrivedAtFinalDestination, "2026-05-14T10:00:00.000Z", {
				eventLocation: location("London Distribution Centre", "GBLDC")
			})
		]
	}
];

// ─── Runner ──────────────────────────────────────────────────────────────────

async function createScenario(client: SupplyChainRestClient, scenario: Scenario): Promise<void> {
	const uid = crypto.randomUUID().slice(0, 8).toUpperCase();
	const consignment = {
		...supplyChainConsignmentPayload,
		identifier: `INT-${uid}`,
		...scenario.overrides
	};

	const { id } = await client.consignmentCreate(consignment);
	console.log(`\n[${scenario.label}] ${consignment.identifier}`);
	console.log(`  ${scenario.description}`);

	for (const eventPayload of scenario.events) {
		const result = await client.consignmentAddEvent(id, eventPayload);
		const code = String(eventPayload.typeCode).replace("twin:", "");
		console.log(`  ✓ ${code} → ${result.id}`);
	}

	// A despatched goods load is also re-submitted as an update (PUT), which is
	// what triggers the ISN despatch signal.
	const hasDespatch = scenario.events.some(
		eventPayload => eventPayload.typeCode === supplyChainEventTypeCodes.Despatched
	);
	if (hasDespatch) {
		await client.consignmentUpdate(id, {
			...consignment,
			summaryDescription: `${scenario.label} - goods load updated at despatch`
		});
		console.log("  ✓ goods load update (PUT)");
	}
}

export default async function consignmentsCreateWithEvents(): Promise<void> {
	const client = await getClient();

	console.log(`\nCreating ${SCENARIOS.length} scenarios...`);

	for (const scenario of SCENARIOS) {
		await createScenario(client, scenario);
	}

	console.log("\n✅ All scenarios created.");
}

seedDocuments

// Copyright 2026 IOTA Stiftung.
// SPDX-License-Identifier: Apache-2.0.
import { readFileSync } from "node:fs";
import { dirname, isAbsolute, resolve } from "node:path";

import { type IUneceDocument, UneceTypes } from "@twin.org/standards-unece";
import type { SupplyChainRestClient } from "@twin.org/supply-chain-rest-client";

import { getClient } from "./lib/client.js";

interface ISeedDocumentInput extends Omit<IUneceDocument, "attachmentBinaryObject"> {
	attachmentBinaryObject?: string;
	attachmentPath?: string;
}

export type ISeedDocumentPayload = IUneceDocument & { attachmentBinaryObject: string };

const UNECE_D23B_CONTEXT = "https://vocabulary.uncefact.org/unece-context-D23B.jsonld";
const LEGACY_UNECE_CONTEXT = "https://vocabulary.uncefact.org/unece-context.jsonld";

/**
 * Upserts documents for the given consignment.
 */
export default async function seedDocuments(): Promise<void> {
	const { consignmentId, documents } = loadConsignmentIdAndDocumentsFromArgs();
	const client = await getClient();

	try {
		await ensureConsignmentExists(client, consignmentId);
		await upsertDocumentsForConsignment(client, consignmentId, documents);
	} catch (err) {
		console.error("Failed to seed documents:", (err as Error).message ?? err);
		process.exit(1);
	}
}

/**
 * Loads consignmentId and documents from process.argv, validates and returns them.
 */
function loadConsignmentIdAndDocumentsFromArgs(): {
	consignmentId: string;
	documents: ISeedDocumentPayload[];
} {
	const [consignmentId, documentsArg] = process.argv.slice(3);
	if (!consignmentId || !documentsArg) {
		console.error("\n* Missing arguments.");
		console.error("> Usage: seedDocuments <CONSIGNMENT_ID> <documents.json | documents-array>");
		console.error(
			"> Example: seedDocuments aig:... apps/supply-chain-node/scripts/seed/documents.json\n"
		);
		process.exit(1);
	}

	try {
		return {
			consignmentId,
			documents: loadDocumentsFromArg(documentsArg)
		};
	} catch (err) {
		console.error("Failed to prepare documents:", (err as Error).message ?? err);
		process.exit(1);
	}
}

/**
 * Load documents from a file path or inline JSON string.
 * @param documentsArg The file path or inline JSON.
 * @returns The normalized document payloads.
 */
export function loadDocumentsFromArg(documentsArg: string): ISeedDocumentPayload[] {
	let documentsData: unknown;
	let baseDirectory = process.cwd();

	if (documentsArg.endsWith(".json")) {
		const filePath = resolve(documentsArg);
		const file = readFileSync(filePath, "utf8");
		documentsData = JSON.parse(file);
		baseDirectory = dirname(filePath);
	} else {
		documentsData = JSON.parse(documentsArg);
	}

	return normalizeDocuments(documentsData, baseDirectory);
}

/**
 * Ensure the target consignment exists before attempting document upserts.
 * @param client The supply chain client.
 * @param consignmentId The target consignment id.
 */
export async function ensureConsignmentExists(
	client: SupplyChainRestClient,
	consignmentId: string
): Promise<void> {
	try {
		await client.consignmentGet(consignmentId);
	} catch (err) {
		const message = err instanceof Error ? err.message : String(err);
		if (
			message.includes("vertexNotFound") ||
			message.includes("consignmentNotFound") ||
			message.includes("auditableItemGraphService.getFailed") ||
			message.includes("supplyChainService.getConsignmentFailed")
		) {
			throw new Error(`Consignment not found: ${consignmentId}`);
		}
		throw new Error(`Failed to load consignment ${consignmentId}: ${message}`);
	}
}

/**
 * Upsert all provided documents for a consignment.
 * @param client The supply chain client.
 * @param consignmentId The target consignment id.
 * @param documents The normalized document payloads.
 */
export async function upsertDocumentsForConsignment(
	client: SupplyChainRestClient,
	consignmentId: string,
	documents: ISeedDocumentPayload[]
): Promise<void> {
	for (const [index, document] of documents.entries()) {
		const result = await client.documentUpsert(consignmentId, document);
		console.log(
			`Upserted document ${index + 1}/${documents.length}: ${document.identifier} (${document.versionId}) -> ${result.aigId}`
		);
	}

	console.log(`Created/updated ${documents.length} documents for consignment ${consignmentId}.`);
}

/**
 * Normalize a single document object or array to an array of payloads.
 * @param documentsData The raw parsed JSON.
 * @param baseDirectory The base directory for resolving attachmentPath.
 * @returns The normalized document payloads.
 */
function normalizeDocuments(documentsData: unknown, baseDirectory: string): ISeedDocumentPayload[] {
	const documents = Array.isArray(documentsData) ? documentsData : [documentsData];

	if (documents.length === 0) {
		throw new Error("Documents must not be an empty array");
	}

	return documents.map((document, index) => normalizeDocument(document, index, baseDirectory));
}

/**
 * Normalize a document and expand attachmentPath to attachmentBinaryObject.
 * @param documentData The raw document input.
 * @param index The document index for error messages.
 * @param baseDirectory The base directory for resolving attachmentPath.
 * @returns The normalized document payload.
 */
function normalizeDocument(
	documentData: unknown,
	index: number,
	baseDirectory: string
): ISeedDocumentPayload {
	if (!documentData || typeof documentData !== "object" || Array.isArray(documentData)) {
		throw new Error(`Document ${index + 1} must be an object`);
	}

	const { attachmentPath, attachmentBinaryObject, ...document } =
		documentData as ISeedDocumentInput;

	const normalizedDocument: ISeedDocumentInput = {
		...document,
		"@context": normalizeUneceContext(document["@context"]),
		type: document.type ?? UneceTypes.Document
	};

	let encodedAttachment = attachmentBinaryObject;

	if (typeof attachmentPath === "string") {
		if (!attachmentPath.trim()) {
			throw new Error(`Document ${index + 1} has an empty attachmentPath`);
		}

		const attachmentFilePath = isAbsolute(attachmentPath)
			? attachmentPath
			: resolve(baseDirectory, attachmentPath);
		encodedAttachment = readFileSync(attachmentFilePath).toString("base64");
	}

	if (typeof encodedAttachment !== "string" || !encodedAttachment.trim()) {
		throw new Error(`Document ${index + 1} must include attachmentPath or attachmentBinaryObject`);
	}

	return {
		...normalizedDocument,
		attachmentBinaryObject: encodedAttachment
	};
}

/**
 * Normalize document context to the UNECE D23B context expected by the server schema.
 * @param context The raw document @context value.
 * @returns The normalized context.
 */
function normalizeUneceContext(context: unknown): IUneceDocument["@context"] {
	if (!context) {
		return UNECE_D23B_CONTEXT;
	}

	if (typeof context === "string") {
		return (
			context === LEGACY_UNECE_CONTEXT ? UNECE_D23B_CONTEXT : context
		) as IUneceDocument["@context"];
	}

	return context as IUneceDocument["@context"];
}

seedDocumentsForNewConsignment

// Copyright 2026 IOTA Stiftung.
// SPDX-License-Identifier: Apache-2.0.
import { createConsignmentPayload } from "./consignmentsCreate.js";
import { getClient } from "./lib/client.js";
import { loadDocumentsFromArg, upsertDocumentsForConsignment } from "./seedDocuments.js";

/**
 * Creates a new consignment and upserts documents for it.
 */
export default async function seedDocumentsForNewConsignment(): Promise<void> {
	const documentsArg = loadDocumentsArgFromArgs();
	const client = await getClient();

	try {
		const consignment = await client.consignmentCreate(createConsignmentPayload());
		const documents = loadDocumentsFromArg(documentsArg);

		console.log(`Created consignment ${consignment.id}.`);
		await upsertDocumentsForConsignment(client, consignment.id, documents);
	} catch (err) {
		console.error("Failed to seed documents for new consignment:", (err as Error).message ?? err);
		process.exit(1);
	}
}

/**
 * Loads the documents argument from process.argv.
 * @returns The file path or inline JSON string.
 */
function loadDocumentsArgFromArgs(): string {
	const [documentsArg] = process.argv.slice(3);
	if (!documentsArg) {
		console.error("\n* Missing arguments.");
		console.error("> Usage: seedDocumentsForNewConsignment <documents.json | documents-array>");
		console.error(
			"> Example: seedDocumentsForNewConsignment apps/supply-chain-node/scripts/seed/documents.json\n"
		);
		process.exit(1);
	}

	return documentsArg;
}

createManagedLocations

// Copyright 2026 IOTA Stiftung.
// SPDX-License-Identifier: Apache-2.0.
import { readFileSync } from "node:fs";

import { getClient } from "./lib/client.js";

/**
 * Creates managed locations for the given org and locations array.
 */
export default async function createManagedLocations(): Promise<void> {
	const { orgId, locations } = loadOrgIdAndLocationsFromArgs();
	const client = await getClient();
	try {
		const result = await client.managedLocationUpsert(orgId, locations);
		const count = Array.isArray(result) ? result.length : locations.length;
		console.log(`Created/updated ${count} managed locations for org ${orgId}.`);
	} catch (err) {
		console.error("Failed to create managed locations:", (err as Error).message ?? err);
		process.exit(1);
	}
}

/**
 * Loads orgId and locations from process.argv, validates and returns them.
 */
function loadOrgIdAndLocationsFromArgs(): {
	orgId: string;
	locations: { operator: string; locationName: string; locationId: string; active: boolean }[];
} {
	const [orgId, locationsArg] = process.argv.slice(3);
	if (!orgId || !locationsArg) {
		console.error("\n* Missing arguments.");
		console.error("> Usage: createManagedLocations <ORG_ID> <locations.json | locations-array>");
		console.error("> Example: createManagedLocations did:... locations.json\n");
		process.exit(1);
	}

	let locations;
	try {
		if (locationsArg.endsWith(".json")) {
			const file = readFileSync(locationsArg, "utf8");
			locations = JSON.parse(file);
		} else {
			locations = JSON.parse(locationsArg);
		}
		if (!Array.isArray(locations)) throw new Error("Locations must be an array");
	} catch (err) {
		console.error("Failed to parse locations:", (err as Error).message ?? err);
		process.exit(1);
	}
	return { orgId, locations };
}

odrlPoliciesRegister

// Copyright 2026 IOTA Stiftung.
// SPDX-License-Identifier: Apache-2.0.
import type { PolicyAdministrationPointRestClient } from "@twin.org/rights-management-rest-client";

import { getPolicyAdministrationClient, getPublisherOrganizationId } from "./lib/client.js";

const { env } = process;

/**
 * The country of the registered ISN the border agency policy authorises,
 * matched against the countryId of a SUPPLY_CHAIN_ISN_ENDPOINTS entry.
 */
const ISN_COUNTRY_ID = env.SUPPLY_CHAIN_SEED_ISN_COUNTRY_ID ?? "GB";

/**
 * Build a policy in the shape the PAP validator accepts: an ODRL Offer with
 * flat refinement constraints (the validator rejects inline "or" groups) and
 * a source on each collection. The target carries no refinement, so the
 * policy covers every consignment the publisher creates.
 * @param assigner The publisher organisation identity.
 * @param assigneeRefinement The party refinements selecting the recipient.
 * @returns The policy.
 */
function buildPolicy(assigner: string, assigneeRefinement: { [key: string]: unknown }[]): unknown {
	return {
		"@context": ["http://www.w3.org/ns/odrl.jsonld", "https://schema.twindev.org/odrl/v1/"],
		"@type": "Offer",
		assigner,
		assignee: {
			source: "urn:supply-chain:notification-recipients",
			refinement: assigneeRefinement
		},
		target: {
			source: "urn:supply-chain:consignments"
		},
		permission: [{ action: "read" }]
	};
}

async function createPolicy(
	client: PolicyAdministrationPointRestClient,
	label: string,
	policy: unknown
): Promise<void> {
	try {
		const uid = await client.create(
			policy as Parameters<PolicyAdministrationPointRestClient["create"]>[0]
		);
		console.log(`Created the ${label} policy: ${uid}`);
	} catch (error) {
		console.error(`Creating the ${label} policy failed:`, JSON.stringify(error, undefined, 2));
		process.exit(1);
	}
}

/**
 * Register the ODRL read policies that authorise the ISN (border agency) and
 * MCP (location operator) notifications for the seeded consignments. The
 * recipient endpoints themselves come from SUPPLY_CHAIN_ISN_ENDPOINTS and
 * SUPPLY_CHAIN_MCP_ENDPOINT on the node.
 */
export default async function odrlPoliciesRegister(): Promise<void> {
	const assigner = await getPublisherOrganizationId();
	console.log(`Registering ODRL read policies for publisher ${assigner}`);

	const client = await getPolicyAdministrationClient();

	await createPolicy(
		client,
		"border agency (ISN)",
		buildPolicy(assigner, [
			{ leftOperand: "information:$.role", operator: "eq", rightOperand: "BorderAgency" },
			{
				leftOperand: "information:$.countryId",
				operator: "eq",
				rightOperand: `unece:CountryId#${ISN_COUNTRY_ID}`
			}
		])
	);

	await createPolicy(
		client,
		"location operator (MCP)",
		buildPolicy(assigner, [
			{ leftOperand: "information:$.role", operator: "eq", rightOperand: "LocationOperator" }
		])
	);

	console.log("Done");
}

lib/client

// Copyright 2026 IOTA Stiftung.
// SPDX-License-Identifier: Apache-2.0.
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath, URL } from "node:url";

import { PolicyAdministrationPointRestClient } from "@twin.org/rights-management-rest-client";
import { SupplyChainRestClient } from "@twin.org/supply-chain-rest-client";
import { config } from "dotenv";

config({ path: [".env", ".env.local"] });

const { env } = process;
const HOST = env.SUPPLY_CHAIN_HOST ?? "";
const PORT = env.SUPPLY_CHAIN_PORT ?? "";
const TENANT_API_KEY = env.SUPPLY_CHAIN_TENANT_API_KEY ?? "";
const ADMIN_USER_NAME = env.SUPPLY_CHAIN_ADMIN_USER_NAME ?? "";
const ADMIN_USER_PASSWORD = env.SUPPLY_CHAIN_ADMIN_USER_PASSWORD ?? "";
const ORGANIZATION_IDENTITY = env.SUPPLY_CHAIN_ORGANIZATION_IDENTITY ?? "";

if (!HOST || !PORT || !TENANT_API_KEY || !ADMIN_USER_NAME || !ADMIN_USER_PASSWORD) {
	console.error(
		"Missing required env vars: SUPPLY_CHAIN_HOST, SUPPLY_CHAIN_PORT, SUPPLY_CHAIN_TENANT_API_KEY, SUPPLY_CHAIN_ADMIN_USER_NAME, SUPPLY_CHAIN_ADMIN_USER_PASSWORD"
	);
	process.exit(1);
}

const CACHE_PATH = fileURLToPath(new URL("../.token-cache.json", import.meta.url));
const BASE_URL = `http://${HOST}:${PORT}`;

interface TokenCache {
	token: string;
	expiry: number;
}

function readCache(): TokenCache | null {
	try {
		const raw = readFileSync(CACHE_PATH, "utf8");
		const cache = JSON.parse(raw) as TokenCache;
		return cache.expiry > Date.now() + 60_000 ? cache : null;
	} catch {
		return null;
	}
}

function extractAccessToken(setCookie: string | null): string | null {
	if (!setCookie) {
		return null;
	}
	const match = setCookie.match(/(?:^|,\s*|;\s*)access_token=([^;]+)/);
	return match ? match[1] : null;
}

async function login(): Promise<TokenCache> {
	const res = await fetch(`${BASE_URL}/authentication/login`, {
		method: "POST",
		headers: { "Content-Type": "application/json", "x-api-key": TENANT_API_KEY },
		body: JSON.stringify({ email: ADMIN_USER_NAME, password: ADMIN_USER_PASSWORD })
	});
	if (!res.ok) {
		console.error(`Login failed: ${res.status} ${await res.text()}`);
		process.exit(1);
	}
	const data = (await res.json()) as { [key: string]: unknown };
	if (typeof data.expiry !== "number") {
		console.error(`Unexpected login response shape: ${JSON.stringify(data)}`);
		process.exit(1);
	}
	const token = extractAccessToken(res.headers.get("set-cookie"));
	if (!token) {
		console.error("Login succeeded but no access_token cookie was returned");
		process.exit(1);
	}
	const cache: TokenCache = { token, expiry: data.expiry };
	writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
	return cache;
}

async function getSession(): Promise<TokenCache> {
	const cached = readCache();
	if (cached) {
		return cached;
	}
	console.log("Token expired or missing, logging in...");
	return login();
}

export async function getClient(): Promise<SupplyChainRestClient> {
	const { token } = await getSession();
	const organization =
		ORGANIZATION_IDENTITY ||
		(JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()) as { org: string }).org;
	return new SupplyChainRestClient({
		endpoint: `${BASE_URL}?organization=${encodeURIComponent(organization)}`,
		headers: {
			Authorization: `Bearer ${token}`,
			"x-api-key": TENANT_API_KEY
		}
	});
}

/**
 * Construct an authenticated policy administration point rest client, used to
 * register the ODRL policies that authorise notifications.
 * @returns The policy administration point rest client.
 */
export async function getPolicyAdministrationClient(): Promise<PolicyAdministrationPointRestClient> {
	const { token } = await getSession();
	const organization =
		ORGANIZATION_IDENTITY ||
		(JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()) as { org: string }).org;
	return new PolicyAdministrationPointRestClient({
		endpoint: `${BASE_URL}?organization=${encodeURIComponent(organization)}`,
		headers: {
			Authorization: `Bearer ${token}`,
			"x-api-key": TENANT_API_KEY
		}
	});
}

/**
 * Resolve the organisation identity that owns the seeded data, used as the
 * assigner when registering ODRL policies. Prefers the configured
 * SUPPLY_CHAIN_ORGANIZATION_IDENTITY, otherwise reads the `org` claim from the
 * session token.
 * @returns The organisation identity (DID).
 */
export async function getPublisherOrganizationId(): Promise<string> {
	const organizationIdentity = env.SUPPLY_CHAIN_ORGANIZATION_IDENTITY ?? "";
	if (organizationIdentity) {
		return organizationIdentity;
	}

	const { token } = await getSession();
	const payloadB64 = token.split(".")[1];
	const org =
		payloadB64 === undefined
			? undefined
			: (JSON.parse(Buffer.from(payloadB64, "base64url").toString()) as { org?: string }).org;

	if (!org) {
		console.error(
			"Could not resolve the organisation identity from the session token; set SUPPLY_CHAIN_ORGANIZATION_IDENTITY."
		);
		process.exit(1);
	}
	return org;
}

On this page