Skip to content

Commit f883f10

Browse files
Merge pull request #57 from kryputh/feat/issue-12-immutable-condition-proof-api
feat: add immutable condition proof API
2 parents 2b58126 + 0e0e436 commit f883f10

File tree

4 files changed

+515
-1
lines changed

4 files changed

+515
-1
lines changed

index.js

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,102 @@ require('dotenv').config();
22

33
const express = require('express');
44
const cors = require('cors');
5+
const {
6+
createConditionProofService,
7+
ConditionProofError,
8+
} = require('./services/conditionProofService');
9+
const {
10+
createFileConditionProofStore,
11+
} = require('./services/conditionProofStore');
12+
13+
const port = process.env.PORT || 3000;
14+
15+
function createApp({ conditionProofService } = {}) {
16+
const app = express();
17+
const proofService =
18+
conditionProofService ||
19+
createConditionProofService({
20+
store: createFileConditionProofStore(),
21+
});
22+
23+
app.use(cors());
24+
app.use(express.json({ limit: '15mb' }));
25+
26+
app.get('/', (req, res) => {
27+
res.json({
28+
project: 'LeaseFlow Protocol',
29+
status: 'Active',
30+
contract_id: 'CAEGD57WVTVQSYWYB23AISBW334QO7WNA5XQ56S45GH6BP3D2AVHKUG4',
31+
});
32+
});
33+
34+
app.post('/leases/:leaseId/condition-proofs', async (req, res) => {
35+
try {
36+
const proof = await proofService.createProof({
37+
leaseId: req.params.leaseId,
38+
moveInStartedAt: req.body?.move_in_started_at,
39+
submittedAt: req.body?.submitted_at,
40+
note: req.body?.note,
41+
photos: req.body?.photos,
42+
});
43+
44+
res.status(201).json(proof);
45+
} catch (error) {
46+
if (error instanceof ConditionProofError) {
47+
return res.status(error.statusCode).json({
48+
error: error.code,
49+
message: error.message,
50+
details: error.details,
51+
});
52+
}
53+
54+
return res.status(500).json({
55+
error: 'CONDITION_PROOF_CREATE_FAILED',
56+
message: 'Unable to record the property condition proof.',
57+
});
58+
}
59+
});
60+
61+
app.get('/leases/:leaseId/condition-proofs', async (req, res) => {
62+
try {
63+
const proofs = await proofService.listProofs(req.params.leaseId);
64+
res.status(200).json({
65+
lease_id: req.params.leaseId,
66+
proofs,
67+
});
68+
} catch (error) {
69+
if (error instanceof ConditionProofError) {
70+
return res.status(error.statusCode).json({
71+
error: error.code,
72+
message: error.message,
73+
details: error.details,
74+
});
75+
}
76+
77+
return res.status(500).json({
78+
error: 'CONDITION_PROOF_LIST_FAILED',
79+
message: 'Unable to load the property condition proofs.',
80+
});
81+
}
82+
});
83+
84+
app.get('/leases/:leaseId/condition-proofs/arbitration-hook', async (req, res) => {
85+
try {
86+
const packet = await proofService.getArbitrationPacket(req.params.leaseId);
87+
res.status(200).json(packet);
88+
} catch (error) {
89+
if (error instanceof ConditionProofError) {
90+
return res.status(error.statusCode).json({
91+
error: error.code,
92+
message: error.message,
93+
details: error.details,
94+
});
95+
}
96+
97+
return res.status(500).json({
98+
error: 'ARBITRATION_PACKET_BUILD_FAILED',
99+
message: 'Unable to build the immutable proof of condition packet.',
100+
});
5101
require('dotenv').config();
6102
const {
7103
createSecurityDepositLockService,
@@ -44,7 +140,11 @@ app.get('/', (req, res) => {
44140
metadata: 'active'
45141
}
46142
});
47-
});
143+
144+
return app;
145+
}
146+
147+
const app = createApp();
48148

49149
app.post('/listings', async (req, res) => {
50150
const { title, price, currency } = req.body;

services/conditionProofService.js

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
const crypto = require('crypto');
2+
3+
const DAY_IN_MS = 24 * 60 * 60 * 1000;
4+
5+
class ConditionProofError extends Error {
6+
constructor(statusCode, code, message, details = {}) {
7+
super(message);
8+
this.name = 'ConditionProofError';
9+
this.statusCode = statusCode;
10+
this.code = code;
11+
this.details = details;
12+
}
13+
}
14+
15+
function toIsoTimestamp(value, fieldName) {
16+
if (!value) {
17+
throw new ConditionProofError(
18+
400,
19+
`MISSING_${fieldName.toUpperCase()}`,
20+
`${fieldName} is required.`,
21+
);
22+
}
23+
24+
const date = new Date(value);
25+
if (Number.isNaN(date.getTime())) {
26+
throw new ConditionProofError(
27+
400,
28+
`INVALID_${fieldName.toUpperCase()}`,
29+
`${fieldName} must be a valid ISO timestamp.`,
30+
);
31+
}
32+
33+
return date.toISOString();
34+
}
35+
36+
function sha256(inputBuffer) {
37+
return crypto.createHash('sha256').update(inputBuffer).digest('hex');
38+
}
39+
40+
function serializeProofForHash(proof) {
41+
return JSON.stringify({
42+
lease_id: proof.lease_id,
43+
move_in_started_at: proof.move_in_started_at,
44+
submitted_at: proof.submitted_at,
45+
note_hash: proof.note_hash,
46+
photos: proof.photos.map((photo) => ({
47+
captured_at: photo.captured_at,
48+
content_hash: photo.content_hash,
49+
filename: photo.filename,
50+
mime_type: photo.mime_type,
51+
})),
52+
});
53+
}
54+
55+
function createConditionProofService({ store }) {
56+
if (!store) {
57+
throw new Error('A condition proof store is required.');
58+
}
59+
60+
return {
61+
async createProof({
62+
leaseId,
63+
moveInStartedAt,
64+
submittedAt,
65+
note,
66+
photos,
67+
}) {
68+
if (!leaseId || !String(leaseId).trim()) {
69+
throw new ConditionProofError(
70+
400,
71+
'MISSING_LEASE_ID',
72+
'leaseId is required.',
73+
);
74+
}
75+
76+
const normalizedLeaseId = String(leaseId).trim();
77+
const normalizedMoveInStartedAt = toIsoTimestamp(
78+
moveInStartedAt,
79+
'move_in_started_at',
80+
);
81+
const normalizedSubmittedAt = toIsoTimestamp(
82+
submittedAt || new Date().toISOString(),
83+
'submitted_at',
84+
);
85+
86+
const moveInStart = new Date(normalizedMoveInStartedAt).getTime();
87+
const submitted = new Date(normalizedSubmittedAt).getTime();
88+
const windowExpiresAt = new Date(moveInStart + DAY_IN_MS).toISOString();
89+
90+
if (submitted < moveInStart || submitted > moveInStart + DAY_IN_MS) {
91+
throw new ConditionProofError(
92+
403,
93+
'CONDITION_PROOF_WINDOW_CLOSED',
94+
'Condition proof submissions are only allowed within the first 24 hours after move-in.',
95+
{
96+
lease_id: normalizedLeaseId,
97+
move_in_started_at: normalizedMoveInStartedAt,
98+
submitted_at: normalizedSubmittedAt,
99+
window_expires_at: windowExpiresAt,
100+
},
101+
);
102+
}
103+
104+
const normalizedNote = typeof note === 'string' ? note.trim() : '';
105+
const normalizedPhotos = Array.isArray(photos) ? photos : [];
106+
if (!normalizedNote && normalizedPhotos.length === 0) {
107+
throw new ConditionProofError(
108+
400,
109+
'EMPTY_CONDITION_PROOF',
110+
'At least one timestamped photo or a condition note is required.',
111+
);
112+
}
113+
114+
const processedPhotos = normalizedPhotos.map((photo, index) => {
115+
if (!photo || typeof photo !== 'object') {
116+
throw new ConditionProofError(
117+
400,
118+
'INVALID_PHOTO_PAYLOAD',
119+
`photos[${index}] must be an object.`,
120+
);
121+
}
122+
123+
const filename = String(photo.filename || '').trim();
124+
const mimeType = String(photo.mime_type || 'application/octet-stream').trim();
125+
const dataBase64 = String(photo.data_base64 || '').trim();
126+
const capturedAt = toIsoTimestamp(
127+
photo.captured_at || normalizedSubmittedAt,
128+
`photos[${index}].captured_at`,
129+
);
130+
131+
if (!filename || !dataBase64) {
132+
throw new ConditionProofError(
133+
400,
134+
'INVALID_PHOTO_PAYLOAD',
135+
`photos[${index}] must include filename and data_base64.`,
136+
);
137+
}
138+
139+
const capturedTime = new Date(capturedAt).getTime();
140+
if (capturedTime < moveInStart || capturedTime > moveInStart + DAY_IN_MS) {
141+
throw new ConditionProofError(
142+
403,
143+
'PHOTO_CAPTURE_OUTSIDE_ALLOWED_WINDOW',
144+
`photos[${index}] was captured outside the first 24 hours after move-in.`,
145+
{
146+
lease_id: normalizedLeaseId,
147+
captured_at: capturedAt,
148+
move_in_started_at: normalizedMoveInStartedAt,
149+
window_expires_at: windowExpiresAt,
150+
},
151+
);
152+
}
153+
154+
let fileBuffer;
155+
try {
156+
fileBuffer = Buffer.from(dataBase64, 'base64');
157+
} catch (_error) {
158+
throw new ConditionProofError(
159+
400,
160+
'INVALID_PHOTO_ENCODING',
161+
`photos[${index}].data_base64 must be valid base64.`,
162+
);
163+
}
164+
165+
if (!fileBuffer.length) {
166+
throw new ConditionProofError(
167+
400,
168+
'INVALID_PHOTO_ENCODING',
169+
`photos[${index}].data_base64 must decode to non-empty content.`,
170+
);
171+
}
172+
173+
return {
174+
filename,
175+
mime_type: mimeType,
176+
captured_at: capturedAt,
177+
size_bytes: fileBuffer.length,
178+
content_hash: sha256(fileBuffer),
179+
};
180+
});
181+
182+
const noteHash = normalizedNote
183+
? sha256(
184+
Buffer.from(
185+
JSON.stringify({
186+
submitted_at: normalizedSubmittedAt,
187+
note: normalizedNote,
188+
}),
189+
),
190+
)
191+
: null;
192+
193+
const proof = {
194+
proof_id: crypto.randomUUID(),
195+
lease_id: normalizedLeaseId,
196+
move_in_started_at: normalizedMoveInStartedAt,
197+
submitted_at: normalizedSubmittedAt,
198+
window_expires_at: windowExpiresAt,
199+
note: normalizedNote,
200+
note_hash: noteHash,
201+
photos: processedPhotos,
202+
};
203+
204+
proof.proof_hash = sha256(Buffer.from(serializeProofForHash(proof)));
205+
await store.save(proof);
206+
return proof;
207+
},
208+
209+
async listProofs(leaseId) {
210+
if (!leaseId || !String(leaseId).trim()) {
211+
throw new ConditionProofError(
212+
400,
213+
'MISSING_LEASE_ID',
214+
'leaseId is required.',
215+
);
216+
}
217+
218+
return store.listByLeaseId(String(leaseId).trim());
219+
},
220+
221+
async getArbitrationPacket(leaseId) {
222+
const proofs = await this.listProofs(leaseId);
223+
if (proofs.length === 0) {
224+
throw new ConditionProofError(
225+
404,
226+
'CONDITION_PROOF_NOT_FOUND',
227+
'No immutable proof of condition was found for this lease.',
228+
{ lease_id: String(leaseId).trim() },
229+
);
230+
}
231+
232+
const immutableProofRootHash = sha256(
233+
Buffer.from(
234+
JSON.stringify(
235+
proofs.map((proof) => ({
236+
proof_id: proof.proof_id,
237+
proof_hash: proof.proof_hash,
238+
})),
239+
),
240+
),
241+
);
242+
243+
return {
244+
lease_id: String(leaseId).trim(),
245+
proof_count: proofs.length,
246+
immutable_proof_root_hash: immutableProofRootHash,
247+
proofs,
248+
soroban_arbitration_hook: {
249+
hook: 'condition-proof-arbitration',
250+
lease_id: String(leaseId).trim(),
251+
immutable_proof_root_hash: immutableProofRootHash,
252+
},
253+
};
254+
},
255+
};
256+
}
257+
258+
module.exports = {
259+
ConditionProofError,
260+
createConditionProofService,
261+
};

0 commit comments

Comments
 (0)