Skip to content

Commit c33c52c

Browse files
authored
feat(gists): allow authors to correct a gist within a 60s edit window (#215)
* feat(gists): allow authors to correct a gist within a 60s edit window * fix(contracts): pin Cargo.lock to fix CI build break soroban-env-host 22.1.3 declares ed25519-dalek as ">=2.0.0" with no upper bound. Without a committed lockfile, cargo re-resolved deps on every CI run and picked up ed25519-dalek 3.0.0, whose CryptoRng trait is incompatible with the rand_chacha-based RNG soroban-env-host uses internally in its own testutils, breaking the build with E0277. Pin ed25519-dalek to 2.2.0 (still satisfies the >=2.0.0 constraint) and commit Cargo.lock so builds are reproducible instead of drifting with upstream releases.
1 parent c195170 commit c33c52c

12 files changed

Lines changed: 460 additions & 9 deletions

Backend/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,26 @@ Content-Type: application/json
176176
5. Persist the record in Postgres
177177
6. Return the created gist
178178

179+
### Correct a Gist
180+
181+
```
182+
PATCH /gists/{id}
183+
Content-Type: application/json
184+
```
185+
186+
```json
187+
{
188+
"content": "Great street food here tonight (fixed typo)",
189+
"author": "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
190+
}
191+
```
192+
193+
Lets an author fix a typo shortly after posting — but only shortly after:
194+
195+
- **60-second edit window.** Measured from the gist's `created_at`. Once elapsed, the endpoint returns `410 Gone` and the content is permanent.
196+
- **Author-gated.** `author` must match the gist's stored author exactly, or the endpoint returns `403 Forbidden`. Gists posted without an author (fully anonymous) can never be edited — there's no identity to verify against.
197+
- **Lineage preserved.** The prior IPFS CID is kept in `previous_cid` and the new content is re-pinned to IPFS, producing a fresh `content_hash`. Nothing is deleted — the edit is an append, not an overwrite of history.
198+
179199
---
180200

181201
## Database Model
@@ -195,6 +215,8 @@ Table: `gists`
195215
| `author_address` | `text` | Nullable — anonymous posts allowed |
196216
| `tx_hash` | `text` | Stellar transaction hash |
197217
| `created_at` | `timestamptz` | |
218+
| `previous_cid` | `text` | Nullable — IPFS CID this gist replaced, set on edit |
219+
| `edited_at` | `timestamptz` | Nullable — set on edit |
198220

199221
---
200222

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddLineageColumn1700000000002 implements MigrationInterface {
4+
name = 'AddLineageColumn1700000000002';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
// Author identity — required to authorize future edits to a gist.
8+
// Nullable: gists posted without an author remain permanently anonymous
9+
// and therefore uneditable (no identity to verify a PATCH against).
10+
await queryRunner.query(`
11+
ALTER TABLE "gists"
12+
ADD COLUMN IF NOT EXISTS "author" VARCHAR(80)
13+
`);
14+
15+
// Lineage — the content_hash (IPFS CID) that was replaced by the
16+
// current content_hash, plus when that replacement happened.
17+
await queryRunner.query(`
18+
ALTER TABLE "gists"
19+
ADD COLUMN IF NOT EXISTS "previous_cid" VARCHAR(100)
20+
`);
21+
22+
await queryRunner.query(`
23+
ALTER TABLE "gists"
24+
ADD COLUMN IF NOT EXISTS "edited_at" TIMESTAMPTZ
25+
`);
26+
}
27+
28+
public async down(queryRunner: QueryRunner): Promise<void> {
29+
await queryRunner.query(`ALTER TABLE "gists" DROP COLUMN IF EXISTS "edited_at"`);
30+
await queryRunner.query(`ALTER TABLE "gists" DROP COLUMN IF EXISTS "previous_cid"`);
31+
await queryRunner.query(`ALTER TABLE "gists" DROP COLUMN IF EXISTS "author"`);
32+
}
33+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsString, MaxLength } from 'class-validator';
3+
4+
export class UpdateGistDto {
5+
@ApiProperty({
6+
description: 'Corrected gist content (max 280 characters)',
7+
example: 'Great coffee spot here! (typo fixed)',
8+
maxLength: 280,
9+
})
10+
@IsString()
11+
@MaxLength(280)
12+
content: string;
13+
14+
@ApiProperty({
15+
description: "Stellar address of the gist's original author; must match the stored author",
16+
example: 'GABC...XYZ',
17+
})
18+
@IsString()
19+
@MaxLength(80)
20+
author: string;
21+
}

Backend/src/gists/entities/gist.entity.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ export class Gist {
2222
@Column({ type: 'varchar', length: 80, nullable: true })
2323
tx_hash: string | null;
2424

25+
@Column({ type: 'varchar', length: 80, nullable: true })
26+
author: string | null;
27+
28+
@Column({ type: 'varchar', length: 100, nullable: true })
29+
previous_cid: string | null;
30+
31+
@Column({ type: 'timestamptz', nullable: true })
32+
edited_at: Date | null;
33+
2534
/**
2635
* PostGIS geography(Point, 4326) column.
2736
* TypeORM has no native geography type — the real column is created
@@ -33,4 +42,8 @@ export class Gist {
3342

3443
@CreateDateColumn({ type: 'timestamptz' })
3544
created_at: Date;
45+
46+
// Not persisted columns — populated by ST_X/ST_Y in raw SELECT queries.
47+
lat?: number;
48+
lon?: number;
3649
}

Backend/src/gists/gist.repository.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ describeIntegration('GistRepository (integration)', () => {
4949
expect(Number(gist.lon)).toBeCloseTo(7.4951, 3);
5050
expect(gist.created_at).toBeDefined();
5151
});
52+
53+
it('should persist the author', async () => {
54+
const gist = await repository.create({
55+
content: 'authored gist',
56+
lat: 9.0579,
57+
lon: 7.4951,
58+
content_hash: 'mock_test_cid',
59+
author: 'GABC...XYZ',
60+
});
61+
62+
expect(gist.author).toBe('GABC...XYZ');
63+
});
5264
});
5365

5466
describe('findNearby', () => {
@@ -143,4 +155,40 @@ describeIntegration('GistRepository (integration)', () => {
143155
expect(result).toBeNull();
144156
});
145157
});
158+
159+
describe('update', () => {
160+
it('should update content and record CID lineage', async () => {
161+
const created = await repository.create({
162+
content: 'before edit',
163+
lat: 9.0579,
164+
lon: 7.4951,
165+
content_hash: 'cid_before',
166+
author: 'GABC...XYZ',
167+
});
168+
169+
const editedAt = new Date();
170+
const updated = await repository.update(created.id, {
171+
content: 'after edit',
172+
content_hash: 'cid_after',
173+
previous_cid: 'cid_before',
174+
edited_at: editedAt,
175+
});
176+
177+
expect(updated).not.toBeNull();
178+
expect(updated!.content).toBe('after edit');
179+
expect(updated!.content_hash).toBe('cid_after');
180+
expect(updated!.previous_cid).toBe('cid_before');
181+
expect(updated!.edited_at).toBeDefined();
182+
});
183+
184+
it('should return null for a non-existent ID', async () => {
185+
const result = await repository.update('00000000-0000-0000-0000-000000000000', {
186+
content: 'no-op',
187+
content_hash: 'cid',
188+
previous_cid: null,
189+
edited_at: new Date(),
190+
});
191+
expect(result).toBeNull();
192+
});
193+
});
146194
});

Backend/src/gists/gist.repository.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ export interface CreateGistData {
2020
content_hash?: string;
2121
stellar_gist_id?: string;
2222
tx_hash?: string;
23+
author?: string;
24+
}
25+
26+
export interface UpdateGistData {
27+
content: string;
28+
content_hash: string;
29+
previous_cid: string | null;
30+
edited_at: Date;
2331
}
2432

2533
export interface UpsertEventData {
@@ -44,26 +52,27 @@ export class GistRepository {
4452
content_hash = null,
4553
stellar_gist_id = null,
4654
tx_hash = null,
55+
author = null,
4756
} = data;
4857

4958
const result = await this.dataSource.query<Gist[]>(
5059
`
5160
INSERT INTO gists (
5261
content, location, location_cell,
53-
content_hash, stellar_gist_id, tx_hash
62+
content_hash, stellar_gist_id, tx_hash, author
5463
)
5564
VALUES (
5665
$1,
5766
ST_SetSRID(ST_MakePoint($2, $3), 4326)::geography,
58-
$4, $5, $6, $7
67+
$4, $5, $6, $7, $8
5968
)
6069
RETURNING
6170
id, content, location_cell, content_hash,
62-
stellar_gist_id, tx_hash, created_at,
71+
stellar_gist_id, tx_hash, author, previous_cid, edited_at, created_at,
6372
ST_X(location::geometry) AS lon,
6473
ST_Y(location::geometry) AS lat
6574
`,
66-
[content, lon, lat, location_cell, content_hash, stellar_gist_id, tx_hash],
75+
[content, lon, lat, location_cell, content_hash, stellar_gist_id, tx_hash, author],
6776
);
6877

6978
return result[0];
@@ -119,7 +128,7 @@ export class GistRepository {
119128
`
120129
SELECT
121130
id, content, location_cell, content_hash,
122-
stellar_gist_id, tx_hash, created_at,
131+
stellar_gist_id, tx_hash, author, previous_cid, edited_at, created_at,
123132
ST_X(location::geometry) AS lon,
124133
ST_Y(location::geometry) AS lat
125134
FROM gists
@@ -131,6 +140,28 @@ export class GistRepository {
131140
return rows[0] ?? null;
132141
}
133142

143+
async update(id: string, data: UpdateGistData): Promise<Gist | null> {
144+
const { content, content_hash, previous_cid, edited_at } = data;
145+
146+
const rows = await this.dataSource.query<Gist[]>(
147+
`
148+
UPDATE gists
149+
SET content = $2,
150+
content_hash = $3,
151+
previous_cid = $4,
152+
edited_at = $5
153+
WHERE id = $1
154+
RETURNING
155+
id, content, location_cell, content_hash,
156+
stellar_gist_id, tx_hash, author, previous_cid, edited_at, created_at,
157+
ST_X(location::geometry) AS lon,
158+
ST_Y(location::geometry) AS lat
159+
`,
160+
[id, content, content_hash, previous_cid, edited_at],
161+
);
162+
return rows[0] ?? null;
163+
}
164+
134165
async findByStellarGistId(stellarGistId: string): Promise<Gist | null> {
135166
const rows = await this.dataSource.query<Gist[]>(
136167
`

Backend/src/gists/gists.controller.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { GistsController } from './gists.controller';
77
import { GistsService } from './gists.service';
88
import { CreateGistDto } from './dto/create-gist.dto';
99
import { QueryGistsDto } from './dto/query-gists.dto';
10+
import { UpdateGistDto } from './dto/update-gist.dto';
1011
import { Gist } from './entities/gist.entity';
1112
import { PaginatedResponse } from '../common/utils/pagination.helper';
1213
describe('GistsController', () => {
@@ -19,6 +20,7 @@ describe('GistsController', () => {
1920
createBatch: jest.fn(),
2021
findNearby: jest.fn(),
2122
findOne: jest.fn(),
23+
update: jest.fn(),
2224
};
2325

2426
const module: TestingModule = await Test.createTestingModule({
@@ -60,6 +62,9 @@ describe('GistsController', () => {
6062
content_hash: 'abc123hash',
6163
stellar_gist_id: null,
6264
tx_hash: null,
65+
author: null,
66+
previous_cid: null,
67+
edited_at: null,
6368
location: 'POINT(7.4951 9.0579)',
6469
created_at: new Date('2026-03-25T04:34:31.334Z'),
6570
...overrides,
@@ -213,4 +218,47 @@ describe('GistsController', () => {
213218
await expect(controller.findOne(id)).rejects.toThrow('Gist not found');
214219
});
215220
});
221+
222+
describe('update()', () => {
223+
it('should call gistsService.update with the id and DTO', async () => {
224+
const id = '123e4567-e89b-12d3-a456-426614174000';
225+
const dto: UpdateGistDto = { content: 'Fixed typo', author: 'GABC...XYZ' };
226+
const result = createMockGist({ id, content: dto.content, author: dto.author });
227+
228+
jest.spyOn(service, 'update').mockResolvedValueOnce(result);
229+
230+
const response = await controller.update(id, dto);
231+
232+
expect(service.update).toHaveBeenCalledWith(id, dto);
233+
expect(response).toEqual(result);
234+
});
235+
236+
it('should propagate a 410 Gone error when the edit window has closed', async () => {
237+
const id = '123e4567-e89b-12d3-a456-426614174000';
238+
const dto: UpdateGistDto = { content: 'Fixed typo', author: 'GABC...XYZ' };
239+
const error = Object.assign(new Error('Edit window has closed for this gist'), {
240+
status: 410,
241+
});
242+
243+
jest.spyOn(service, 'update').mockRejectedValueOnce(error);
244+
245+
await expect(controller.update(id, dto)).rejects.toThrow(
246+
'Edit window has closed for this gist',
247+
);
248+
});
249+
250+
it('should propagate a forbidden error when the author does not match', async () => {
251+
const id = '123e4567-e89b-12d3-a456-426614174000';
252+
const dto: UpdateGistDto = { content: 'Fixed typo', author: 'GIMPOSTOR' };
253+
const error = Object.assign(new Error('Only the original author may edit this gist'), {
254+
status: 403,
255+
});
256+
257+
jest.spyOn(service, 'update').mockRejectedValueOnce(error);
258+
259+
await expect(controller.update(id, dto)).rejects.toThrow(
260+
'Only the original author may edit this gist',
261+
);
262+
});
263+
});
216264
});

Backend/src/gists/gists.controller.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@ import {
44
Controller,
55
Get,
66
Post,
7+
Patch,
78
Body,
89
Param,
910
Query,
1011
ParseArrayPipe,
1112
} from '@nestjs/common';
1213
import { Throttle, SkipThrottle } from '@nestjs/throttler';
13-
import { ApiBody, ApiOperation, ApiTags, ApiParam } from '@nestjs/swagger';
14+
import { ApiBody, ApiOperation, ApiTags, ApiParam, ApiResponse } from '@nestjs/swagger';
1415
import { GistsService } from './gists.service';
1516
import { CreateGistDto } from './dto/create-gist.dto';
1617
import { QueryGistsDto } from './dto/query-gists.dto';
18+
import { UpdateGistDto } from './dto/update-gist.dto';
1719

1820
const MAX_GISTS_PER_BATCH = 10;
1921

@@ -70,4 +72,16 @@ export class GistsController {
7072
findOne(@Param('id') id: string) {
7173
return this.gistsService.findOne(id);
7274
}
75+
76+
@Patch(':id')
77+
@Throttle({ default: { limit: 10, ttl: 60000 } })
78+
@ApiOperation({
79+
summary: 'Correct a gist within its 60s edit window (same author only)',
80+
})
81+
@ApiParam({ name: 'id', description: 'Gist UUID' })
82+
@ApiResponse({ status: 403, description: 'Caller is not the original author' })
83+
@ApiResponse({ status: 410, description: 'Edit window has closed' })
84+
update(@Param('id') id: string, @Body() dto: UpdateGistDto) {
85+
return this.gistsService.update(id, dto);
86+
}
7387
}

0 commit comments

Comments
 (0)