Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ curl http://localhost:9000/agreements/1/convert/html

`template` and `data` go together — `data` has to validate against the model in
the archive `template` points at — so the snippet above is a skeleton, not a
runnable command. See
runnable command. A bare `http(s)` URL in `template` is stored as the Concerto
relationship `resource:org.accordproject.protocol@1.0.0.Template#` plus the
percent-encoded URL. See
[Using a Template from templates.accordproject.org](./server/README.md#using-a-template-from-templatesaccordprojectorg)
for a complete worked request, the Cicero version compatibility check, and
`POST /templates/archive` — the explicit upload path for custom archives or
Expand Down
13 changes: 11 additions & 2 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,13 @@ its URL throughout.

`POST /agreements` accepts an `http(s)` URL as its `template` and resolves it on
the way through (see `handlers/retrievers/HttpTemplateRetriever.ts`), so a
single request gets you a usable agreement — no `/templates` call at all:
single request gets you a usable agreement — no `/templates` call at all.

A bare `http(s)://` or `ap://` value is normalized to the Concerto relationship
`resource:org.accordproject.protocol@1.0.0.Template#` plus the percent-encoded
identifier before it is validated and saved. Sending that relationship yourself
is stored unchanged. Lookup and the HTTP retriever unwrap it back to the
original URL or `ap://` URI.

```bash
curl --request POST \
Expand Down Expand Up @@ -358,7 +364,10 @@ publishes. Name and version come from the archive's own `package.json`, and the
fragment is its content hash, so the URI identifies those exact bytes.

An agreement then references that URI as its `template`, in place of the URL
used in the quick start — everything else about the agreement is unchanged:
used in the quick start — everything else about the agreement is unchanged.
The `ap://` value below is accepted as written and stored as the
`resource:org.accordproject.protocol@1.0.0.Template#` relationship (the `#hash`
is percent-encoded so it stays part of the identifier):

```json
{
Expand Down
253 changes: 252 additions & 1 deletion server/handlers/agreements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import request from 'supertest';
import express from 'express';
import { jest } from '@jest/globals';
import agreementsRouter from './agreements';
import { templateUriFromReference, validateAgreementBody } from '../services/agreementService';
import { Agreement, Template, AgreementInsertSchema } from '../db/schema';
import * as templateBuilder from './templatebuilder';
import { TemplateArchiveProcessor } from '@accordproject/template-engine';
Expand Down Expand Up @@ -643,6 +644,140 @@ describe('POST / - Agreement Creation with External Template', () => {
expect(templateInsertPayload).toHaveProperty('metadata');
expect(templateInsertPayload).toHaveProperty('logo');
});

it('fetches the bare https URL after storing it as a resource relationship', async () => {
(AgreementInsertSchema.safeParse as any) = jest.fn().mockReturnValue({ success: true });
mockDb.insert = jest.fn().mockReturnThis();
mockDb.values = jest.fn().mockReturnThis();
mockDb.onConflictDoNothing = jest.fn<any>().mockResolvedValue([]);
mockDb.returning = jest.fn<any>().mockResolvedValue([{ id: 1 }]);
mockDb.select.mockReturnValue(mockDb);
mockDb.from.mockReturnValue(mockDb);
mockDb.where.mockReturnValue(mockDb);
mockDb.limit.mockResolvedValueOnce([]);

const valModule = require('./concertovalidation');
const actual = jest.requireActual('./concertovalidation') as any;
valModule.concertoValidation.mockReset();
valModule.concertoValidation.mockImplementation(actual.concertoValidation);

const template = 'https://templates.accordproject.org/archives/latedeliveryandpenalty@0.1.0.cta';
const response = await request(app)
.post('/agreements/')
.send({
uri: 'apap://agreement-ldp',
template,
agreementStatus: 'DRAFT',
data: {
$class: 'org.example.TemplateModel',
price: 1,
},
});

expect(response.status).toBe(200);
expect((global as any).fetch).toHaveBeenCalledWith(template, expect.any(Object));
const agreementInsert = mockDb.values.mock.calls[1][0];
expect(agreementInsert.template).toBe(
'resource:org.accordproject.protocol@1.0.0.Template#' + encodeURIComponent(template),
);
});
});

describe('POST / - bare template URIs are stored as Concerto relationships', () => {
const relationshipPrefix = 'resource:org.accordproject.protocol@1.0.0.Template#';
const apUri = 'ap://latedeliveryandpenalty@1.0.0#abc123';
const data = {
$class: 'org.example.TemplateModel',
clauseId: 'c1',
price: 10,
};

beforeEach(() => {
(AgreementInsertSchema.safeParse as any) = jest.fn().mockReturnValue({ success: true });
mockDb.insert = jest.fn().mockReturnThis();
mockDb.values = jest.fn().mockReturnThis();
mockDb.returning = jest.fn<any>().mockResolvedValue([{ id: 1 }]);
const valModule = require('./concertovalidation');
const actual = jest.requireActual('./concertovalidation') as any;
valModule.concertoValidation.mockReset();
valModule.concertoValidation.mockImplementation(actual.concertoValidation);
});

it('stores an ap:// template URI as a percent-encoded resource relationship', async () => {
const response = await request(app)
.post('/agreements/')
.send({
uri: 'apap://agreement-1',
template: apUri,
agreementStatus: 'DRAFT',
data,
state: { step: 1 },
});

expect(response.status).toBe(200);
expect(mockDb.insert).toHaveBeenCalledWith(Agreement);
const inserted = mockDb.values.mock.calls[0][0];
expect(inserted.template).toBe(relationshipPrefix + encodeURIComponent(apUri));
expect(inserted.data).toBe(JSON.stringify(data));
expect(inserted.state).toBe(JSON.stringify({ step: 1 }));
});

it('stores an https template URL as a resource relationship', async () => {
const template = 'https://templates.accordproject.org/archives/latedeliveryandpenalty@1.0.0.cta';
const result = await validateAgreementBody({
uri: 'apap://agreement-ldp',
template,
agreementStatus: 'DRAFT',
data,
});

expect(result.success).toBe(true);
// Concerto decodes the identifier when it serializes the relationship.
expect(result.data.template).toBe(relationshipPrefix + template);
expect(result.data.$class).toBe('org.accordproject.protocol@1.0.0.Agreement');
});

it('leaves a resource relationship unchanged', async () => {
const template = 'resource:org.accordproject.protocol@1.0.0.Template#demo://template/x';
const result = await validateAgreementBody({
uri: 'apap://agreement-1',
template,
agreementStatus: 'DRAFT',
data,
});

expect(result.success).toBe(true);
expect(result.data.template).toBe(template);
});

it('still rejects an invalid template scheme', async () => {
const response = await request(app)
.post('/agreements/')
.send({
uri: 'apap://agreement-1',
template: 'ftp://example.com/archive.cta',
agreementStatus: 'DRAFT',
data,
});

expect(response.status).toBe(400);
expect(response.body.details[0].message).toContain('Invalid URI scheme');
expect(mockDb.insert).not.toHaveBeenCalled();
});

it('still rejects an agreement whose status is not in the model', async () => {
const response = await request(app)
.post('/agreements/')
.send({
uri: 'apap://agreement-1',
template: apUri,
agreementStatus: 'NOT_A_STATUS',
data,
});

expect(response.status).toBe(400);
expect(mockDb.insert).not.toHaveBeenCalled();
});
});

describe('PUT /:id - agreement record immutability once COMPLETED/SUPERSEDED', () => {
Expand Down Expand Up @@ -804,6 +939,122 @@ describe('POST / - Agreement Creation with External Template', () => {
expect(putDb.update).not.toHaveBeenCalled();
});

function useRealConcertoValidation() {
const actual = jest.requireActual('./concertovalidation') as any;
const valModule = require('./concertovalidation');
valModule.concertoValidation.mockReset();
valModule.concertoValidation.mockImplementation(actual.concertoValidation);
}

it('persists Concerto\'s serialized body for an ap:// template on PUT', async () => {
useRealConcertoValidation();
const template = 'ap://latedeliveryandpenalty@1.0.0#abc123';
const canonicalTemplate = 'resource:org.accordproject.protocol@1.0.0.Template#' + template;
putDb.limit.mockResolvedValueOnce([{
id: 3,
uri: 'apap://agreement-1',
agreementStatus: 'DRAFT',
template: 'resource:org.accordproject.protocol@1.0.0.Template#dan2',
data: { price: 1 },
}]);
putDb.returning.mockResolvedValueOnce([{ id: 3 }]);

const res = await request(putApp)
.put('/agreements/3')
.send({
uri: 'apap://agreement-1',
template,
agreementStatus: 'DRAFT',
data: { price: 10 },
});

expect(res.status).toBe(200);
const written = putDb.set.mock.calls[0][0];
expect(written.$class).toBe('org.accordproject.protocol@1.0.0.Agreement');
expect(written.template).toBe(canonicalTemplate);
expect(typeof written.data).toBe('string');
expect(templateUriFromReference(written.template)).toBe(template);
});

it('persists Concerto\'s decoded https relationship on PUT', async () => {
useRealConcertoValidation();
const template = 'https://templates.accordproject.org/archives/latedeliveryandpenalty@1.0.0.cta';
putDb.limit.mockResolvedValueOnce([{
id: 4,
uri: 'apap://agreement-ldp',
agreementStatus: 'DRAFT',
template: 'resource:org.accordproject.protocol@1.0.0.Template#dan2',
data: { price: 1 },
}]);
putDb.returning.mockResolvedValueOnce([{ id: 4 }]);

const res = await request(putApp)
.put('/agreements/4')
.send({
uri: 'apap://agreement-ldp',
template,
agreementStatus: 'DRAFT',
data: { price: 10 },
});

expect(res.status).toBe(200);
const written = putDb.set.mock.calls[0][0];
expect(written.template).toBe(
'resource:org.accordproject.protocol@1.0.0.Template#' + template,
);
expect(templateUriFromReference(written.template)).toBe(template);
});

it('preserves a template URI\'s own percent escapes through POST and PUT validation', async () => {
useRealConcertoValidation();
const template = 'https://example.org/a%20b.cta?x=a%2Fb';
const body = {
uri: 'apap://agreement-escaped',
template,
agreementStatus: 'DRAFT',
data: { price: 10 },
};
const result = await validateAgreementBody(body);
expect(result.success).toBe(true);
expect(templateUriFromReference(body.template)).toBe(template);
expect(templateUriFromReference(result.data.template)).toBe(template);
expect(result.data.template).toBe(
'resource:org.accordproject.protocol@1.0.0.Template#' + encodeURI(template),
);
});

it.each(['COMPLETED', 'SUPERSEDED'])(
'accepts a no-op resend of a POST-created agreement once %s',
async (agreementStatus) => {
useRealConcertoValidation();
const sent = {
uri: 'apap://agreement-1',
template: 'ap://latedeliveryandpenalty@1.0.0#abc123',
agreementStatus,
data: { price: 10 },
};
// Capture what POST actually inserts, without serializer-only $class.
mockDb.returning.mockResolvedValueOnce([{ id: 5 }]);
const created = await request(app).post('/agreements/').send(sent);
expect(created.status).toBe(200);
const inserted = mockDb.values.mock.calls[0][0];
expect(inserted.template).toBe(
'resource:org.accordproject.protocol@1.0.0.Template#' + encodeURIComponent(sent.template),
);
expect(inserted).not.toHaveProperty('$class');
putDb.limit.mockResolvedValueOnce([{ id: 5, ...inserted }]);
putDb.returning.mockResolvedValueOnce([{ id: 5, ...inserted }]);

const res = await request(putApp)
.put('/agreements/5')
.send(sent);

expect(res.status).toBe(200);
expect(putDb.update).toHaveBeenCalled();
expect(templateUriFromReference(putDb.set.mock.calls[0][0].template)).toBe(sent.template);
},
);

it('still allows a signature to be added while SIGNING (only data is frozen)', async () => {
const existing = { id: 7, agreementStatus: 'SIGNING', data: { price: 10 }, signatures: [] as any[] };
putDb.limit.mockResolvedValueOnce([existing]);
Expand All @@ -819,4 +1070,4 @@ describe('POST / - Agreement Creation with External Template', () => {
expect(putDb.update).toHaveBeenCalled();
});
});
});
});
9 changes: 6 additions & 3 deletions server/handlers/agreements.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import express from 'express';
import { Agreement, AgreementInsertSchema, Template as DbTemplate } from '../db/schema';
import { buildCrudRouter } from './crud';
import { concertoValidation } from './concertovalidation';
import { extractTemplateForDatabase } from './templatebuilder';
import { eq } from 'drizzle-orm';
import { HttpTemplateRetriever } from './retrievers/HttpTemplateRetriever';
Expand All @@ -18,6 +17,7 @@ import {
listAgreementsPaged,
triggerAgreement,
templateUriFromReference,
validateAgreementBody,
} from '../services/agreementService';
import { buildAgreementLegalContext, getAgreementTerms, resolvePublicBaseUrl } from '../services/lcpService';
import { asyncHandler } from '../middleware/errorHandler';
Expand All @@ -43,7 +43,7 @@ router.post('/', asyncHandler(async (req, res) => {
return res.status(400).json({ error: 'Schema validation failed', details: zodValidation.error.issues });
}

const { success, error } = await concertoValidation('Agreement', req.body);
const { success, error } = await validateAgreementBody(req.body);
if (!success) {
return res.status(400).json({ error: 'Invalid request body', details: error.errors });
}
Expand Down Expand Up @@ -78,6 +78,9 @@ router.post('/', asyncHandler(async (req, res) => {
}
}

// req.body.template keeps validateAgreementBody's encodeURIComponent spelling
// for bare URIs; PUT uses result.data's Concerto encodeURI spelling instead.
// Keep both forms covered by assertTemplateNotInUse and the frozen-record guard.
const insertData = {
...req.body,
templateHash: currentHash,
Expand All @@ -93,7 +96,7 @@ const crudRouter = buildCrudRouter({
typeName: 'Agreement',
// ponytail: cast schema to any due to zod v3 -> v4 upgrade depth-instantiation
// issue with drizzle-zod. Runtime unaffected.
validateBody: { schema: AgreementInsertSchema as any, custom: (body) => concertoValidation('Agreement', body) },
validateBody: { schema: AgreementInsertSchema as any, custom: (body) => validateAgreementBody(body) },
guardUpdate: (existing, body) => assertAgreementRecordMutable(existing, body),
listService: (context, opts) => listAgreementsPaged(context, opts),
});
Expand Down
Loading
Loading