/**
* DynamoDB-backed Mandate Store
* Production replacement for the in-memory MandateStore in src/ap2/mandate-store.ts.
*
* DynamoDB schema:
* - Table key: mandateId (S)
* - GSI "checkoutIndex": key checkoutId (S)
* - payload stored as JSON string to preserve type discrimination
*/
import {
PutCommand,
GetCommand,
QueryCommand,
UpdateCommand,
ScanCommand,
BatchWriteCommand,
} from '@aws-sdk/lib-dynamodb';
import { getDocClient } from './client.js';
import type { Mandate } from '../types.js';
export class DynamoMandateStore {
private readonly tableName: string;
constructor(tableName: string) {
this.tableName = tableName;
}
/**
* Store a mandate.
* Uses a condition expression to prevent overwriting an existing mandate.
*/
async store(mandate: Mandate): Promise<void> {
const checkoutId = this.extractCheckoutId(mandate);
await getDocClient().send(
new PutCommand({
TableName: this.tableName,
Item: {
mandateId: mandate.id,
checkoutId: checkoutId ?? 'none',
type: mandate.type,
status: mandate.status,
issuer: mandate.issuer,
subject: mandate.subject,
payload: JSON.stringify(mandate.payload),
signature: mandate.signature,
issued_at: mandate.issued_at,
expires_at: mandate.expires_at,
},
ConditionExpression: 'attribute_not_exists(mandateId)',
}),
);
}
/**
* Retrieve a mandate by its ID.
* Returns null if the mandate does not exist.
*/
async get(id: string): Promise<Mandate | null> {
const result = await getDocClient().send(
new GetCommand({
TableName: this.tableName,
Key: { mandateId: id },
}),
);
if (!result.Item) return null;
return this.itemToMandate(result.Item);
}
/**
* Retrieve all mandates associated with a checkout session.
* Uses the checkoutIndex GSI.
*/
async getByCheckout(checkoutId: string): Promise<Mandate[]> {
const result = await getDocClient().send(
new QueryCommand({
TableName: this.tableName,
IndexName: 'checkoutIndex',
KeyConditionExpression: 'checkoutId = :cid',
ExpressionAttributeValues: { ':cid': checkoutId },
}),
);
if (!result.Items || result.Items.length === 0) return [];
return result.Items.map((item) => this.itemToMandate(item));
}
/**
* Revoke a mandate by setting its status to "revoked".
* Throws if the mandate does not exist.
*/
async revoke(id: string): Promise<void> {
await getDocClient().send(
new UpdateCommand({
TableName: this.tableName,
Key: { mandateId: id },
UpdateExpression: 'SET #status = :revoked',
ExpressionAttributeNames: { '#status': 'status' },
ExpressionAttributeValues: { ':revoked': 'revoked' },
ConditionExpression: 'attribute_exists(mandateId)',
}),
);
}
/**
* Remove all expired mandates from the store.
* In production, DynamoDB TTL on expires_at handles automatic deletion.
* This method exists for manual cleanup or environments without TTL configured.
*/
async cleanup(): Promise<number> {
const now = new Date().toISOString();
const result = await getDocClient().send(
new ScanCommand({
TableName: this.tableName,
FilterExpression: 'expires_at < :now',
ExpressionAttributeValues: { ':now': now },
}),
);
if (!result.Items || result.Items.length === 0) return 0;
// BatchWrite supports up to 25 items per request
const items = result.Items;
let removed = 0;
for (let i = 0; i < items.length; i += 25) {
const batch = items.slice(i, i + 25);
const deleteRequests = batch.map((item) => ({
DeleteRequest: {
Key: { mandateId: item['mandateId'] as string },
},
}));
await getDocClient().send(
new BatchWriteCommand({
RequestItems: {
[this.tableName]: deleteRequests,
},
}),
);
removed += batch.length;
}
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;
}
/**
* Convert a DynamoDB item back into a Mandate object.
*/
private itemToMandate(item: Record<string, unknown>): Mandate {
return {
id: item['mandateId'] as string,
type: item['type'] as Mandate['type'],
status: item['status'] as Mandate['status'],
issuer: item['issuer'] as string,
subject: item['subject'] as string,
payload: JSON.parse(item['payload'] as string),
signature: item['signature'] as string,
issued_at: item['issued_at'] as string,
expires_at: item['expires_at'] as string,
};
}
}