/**
* AP2 Mandate Store
* In-memory mandate persistence with a DynamoDB-ready interface.
* Each method is annotated with comments showing where DynamoDB calls would replace
* the in-memory operations for production deployment.
*/
import type { Mandate } from '../types.js';
export class MandateStore {
/** Primary store: mandate ID -> Mandate */
private readonly mandates: Map<string, Mandate> = new Map();
/** Secondary index: checkout ID -> Set of mandate IDs */
private readonly checkoutIndex: Map<string, Set<string>> = new Map();
/**
* Store a mandate.
*
* @param mandate - The mandate to persist.
*/
async store(mandate: Mandate): Promise<void> {
// DynamoDB: PutItem to mandates table
// await dynamodb.put({
// TableName: config.dynamodb.mandatesTable,
// Item: mandate,
// ConditionExpression: 'attribute_not_exists(id)', // prevent overwrites
// }).promise();
this.mandates.set(mandate.id, { ...mandate });
// Maintain checkout index for getByCheckout lookups
const checkoutId = this.extractCheckoutId(mandate);
if (checkoutId) {
// DynamoDB: This would be a GSI (Global Secondary Index) on checkout_id
let ids = this.checkoutIndex.get(checkoutId);
if (!ids) {
ids = new Set();
this.checkoutIndex.set(checkoutId, ids);
}
ids.add(mandate.id);
}
}
/**
* Retrieve a mandate by its ID.
*
* @param id - Mandate ID.
* @returns The mandate if found, null otherwise.
*/
async get(id: string): Promise<Mandate | null> {
// DynamoDB: GetItem from mandates table
// const result = await dynamodb.get({
// TableName: config.dynamodb.mandatesTable,
// Key: { id },
// }).promise();
// return result.Item as Mandate | null;
const mandate = this.mandates.get(id);
return mandate ? { ...mandate } : null;
}
/**
* Retrieve all mandates associated with a checkout session.
*
* @param checkoutId - The checkout session ID.
* @returns Array of mandates linked to this checkout.
*/
async getByCheckout(checkoutId: string): Promise<Mandate[]> {
// DynamoDB: Query using GSI on checkout_id
// const result = await dynamodb.query({
// TableName: config.dynamodb.mandatesTable,
// IndexName: 'checkout-id-index',
// KeyConditionExpression: 'checkout_id = :cid',
// ExpressionAttributeValues: { ':cid': checkoutId },
// }).promise();
// return result.Items as Mandate[];
const ids = this.checkoutIndex.get(checkoutId);
if (!ids) return [];
const mandates: Mandate[] = [];
for (const id of ids) {
const mandate = this.mandates.get(id);
if (mandate) {
mandates.push({ ...mandate });
}
}
return mandates;
}
/**
* Revoke a mandate by setting its status to "revoked".
*
* @param id - The mandate ID to revoke.
* @throws Error if the mandate is not found.
*/
async revoke(id: string): Promise<void> {
// DynamoDB: UpdateItem to set status = "revoked"
// await dynamodb.update({
// TableName: config.dynamodb.mandatesTable,
// Key: { id },
// UpdateExpression: 'SET #status = :revoked',
// ExpressionAttributeNames: { '#status': 'status' },
// ExpressionAttributeValues: { ':revoked': 'revoked' },
// ConditionExpression: 'attribute_exists(id)',
// }).promise();
const mandate = this.mandates.get(id);
if (!mandate) {
throw new Error(`Mandate not found: ${id}`);
}
mandate.status = 'revoked';
}
/**
* Remove all expired mandates from the store.
* In production, this would be triggered by a DynamoDB TTL or a scheduled Lambda.
*
* @returns The number of mandates removed.
*/
async cleanup(): Promise<number> {
// DynamoDB: Typically handled by TTL attribute on expires_at
// - Set TTL on the table to auto-delete expired items.
// - Alternatively, scan with FilterExpression:
// await dynamodb.scan({
// TableName: config.dynamodb.mandatesTable,
// FilterExpression: 'expires_at < :now',
// ExpressionAttributeValues: { ':now': new Date().toISOString() },
// }).promise();
// - Then batch-delete the results.
const now = Date.now();
let removed = 0;
for (const [id, mandate] of this.mandates) {
const expiresAt = new Date(mandate.expires_at).getTime();
if (!isNaN(expiresAt) && now > expiresAt) {
this.mandates.delete(id);
// Clean up the checkout index as well
const checkoutId = this.extractCheckoutId(mandate);
if (checkoutId) {
const ids = this.checkoutIndex.get(checkoutId);
if (ids) {
ids.delete(id);
if (ids.size === 0) {
this.checkoutIndex.delete(checkoutId);
}
}
}
removed++;
}
}
return removed;
}
// ────────────────────────────────────────
// Internal helpers
// ────────────────────────────────────────
/**
* Extract the checkout_id from a mandate's payload, if present.
* Cart and Payment payloads contain checkout_id; Intent does not.
*/
private extractCheckoutId(mandate: Mandate): string | null {
if (mandate.type === 'cart') {
return (mandate.payload as { checkout_id: string }).checkout_id;
}
if (mandate.type === 'payment') {
return (mandate.payload as { checkout_id: string }).checkout_id;
}
return null;
}
}