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
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
-- v2: DELETE /developers/me needs to hard-delete a developers row while
-- deliberately keeping its developer_history rows (the audit trail must
-- survive profile deletion). developer_history.developer_id currently has a
-- hard FK to developers(id) with no ON DELETE action, so as soon as D1
-- enforces foreign keys (it does, by default) that delete fails outright —
-- confirmed empirically against local D1 before writing this migration.
--
-- SQLite can't ALTER a FK constraint away, so the table is rebuilt without
-- it. developer_id keeps its NOT NULL and its historical value once a
-- developer is deleted; it's just no longer a live, enforced reference.
-- Everything else (columns, the append-only triggers, the index) is
-- unchanged. Existing rows are preserved, re-inserted in original rowid
-- order so the "ORDER BY changed_at DESC, rowid DESC" tie-break in
-- listHistory keeps meaning what it always meant.

CREATE TABLE developer_history_new (
id TEXT PRIMARY KEY NOT NULL,
developer_id TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
url TEXT,
changed_by TEXT NOT NULL REFERENCES users(id),
changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO developer_history_new (id, developer_id, type, name, url, changed_by, changed_at)
SELECT id, developer_id, type, name, url, changed_by, changed_at
FROM developer_history
ORDER BY rowid;

DROP TABLE developer_history;
ALTER TABLE developer_history_new RENAME TO developer_history;

CREATE INDEX IF NOT EXISTS idx_developer_history_developer_changed_at
ON developer_history(developer_id, changed_at);

CREATE TRIGGER IF NOT EXISTS trg_developer_history_no_update
BEFORE UPDATE ON developer_history
BEGIN
SELECT RAISE(ABORT, 'developer_history is append-only: rows cannot be updated');
END;

CREATE TRIGGER IF NOT EXISTS trg_developer_history_no_delete
BEFORE DELETE ON developer_history
BEGIN
SELECT RAISE(ABORT, 'developer_history is append-only: rows cannot be deleted');
END;
182 changes: 182 additions & 0 deletions src/services/extensions/v2/developers-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,188 @@ export class DevelopersDatabase {
}
}

// Diagnoses why the guarded delete in deleteOwn() below affected zero
// rows: distinguishes no-longer-owned/nonexistent from the two blocking
// conditions, without reopening the race the guard already closed.
private async deletionBlockedError(
developerId: string,
userId: string
): Promise<{ code: "NOT_FOUND" | "CONFLICT"; message: string }> {
const developer = await this.db
.prepare("SELECT owner_user_id FROM developers WHERE id = ?")
.bind(developerId)
.first<{ owner_user_id: string | null }>();

if (!developer || developer.owner_user_id !== userId) {
return { code: "NOT_FOUND", message: "Developer not found" };
}

const extensionCount = await this.db
.prepare("SELECT COUNT(*) AS count FROM extensions WHERE author_id = ?")
.bind(developerId)
.first<{ count: number }>();
const extensionsCount = extensionCount?.count ?? 0;
if (extensionsCount > 0) {
return {
code: "CONFLICT",
message: `You have ${extensionsCount} published extension(s) under this profile. Transfer ownership or remove them before deleting it.`
};
}

const pendingCount = await this.db
.prepare(
"SELECT COUNT(*) AS count FROM extension_submissions WHERE developer_id = ? AND status = 'pending'"
)
.bind(developerId)
.first<{ count: number }>();
if ((pendingCount?.count ?? 0) > 0) {
return {
code: "CONFLICT",
message:
"You have a pending submission under review. Wait for it to be resolved before deleting your profile."
};
}

// The guard failed but a fresh look finds nothing wrong — whatever
// blocked it (someone else's transfer/claim landing, a submission
// that has since been resolved) has already cleared. Ask the caller
// to retry rather than guessing at a reason that's no longer true.
return {
code: "CONFLICT",
message:
"Your profile changed while processing this request. Please try again."
};
}

// Permanently removes the caller's own developer profile, for a
// privacy-focused account-deletion flow. Refuses while anything would be
// left dangling in a way that isn't just historical record-keeping:
// published extensions (someone still needs to own them) and pending
// submissions (nothing left to approve/reject against once the named
// developer is gone). developer_history is deliberately left alone —
// it's an append-only audit log, moderator-only, never rendered publicly,
// and 0009_drop_developer_history_fk.sql dropped its FK to developers(id)
// specifically so a deleted developer's history rows can outlive it.
async deleteOwn(
userId: string
): Promise<DatabaseResult<{ id: string; deleted: true }>> {
try {
const developer = await this.db
.prepare("SELECT id FROM developers WHERE owner_user_id = ?")
.bind(userId)
.first<{ id: string }>();

if (!developer) {
return {
data: null,
error: { message: "Developer not found", code: "NOT_FOUND" }
};
}

if (!this.db.batch) {
return databaseError(
"deleteOwn",
new Error("Database adapter does not support batch operations")
);
}

// Every statement re-checks eligibility (still owned by this caller,
// no published extensions, no pending submission) at the moment it
// runs, rather than trusting the SELECT above: ownership can move
// (an accepted transfer/claim) and a new extension or pending
// submission can appear between that check and this write, and this
// delete is the caller's only authorization check. The same guard is
// repeated on all three statements — not just the last — so they're
// all-or-nothing: if it fails, nothing here is touched, instead of
// transfers/claims being deleted out from under a profile whose own
// deletion then gets blocked.
const deleteTransfersStmt = this.db
.prepare(
`DELETE FROM developer_transfers
WHERE developer_id = ?
AND EXISTS (
SELECT 1 FROM developers
WHERE developers.id = developer_transfers.developer_id
AND developers.owner_user_id = ?
AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id)
AND NOT EXISTS (
SELECT 1 FROM extension_submissions
WHERE extension_submissions.developer_id = developers.id
AND extension_submissions.status = 'pending'
)
)`
)
.bind(developer.id, userId);

const deleteClaimsStmt = this.db
.prepare(
`DELETE FROM developer_claims
WHERE developer_id = ?
AND EXISTS (
SELECT 1 FROM developers
WHERE developers.id = developer_claims.developer_id
AND developers.owner_user_id = ?
AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id)
AND NOT EXISTS (
SELECT 1 FROM extension_submissions
WHERE extension_submissions.developer_id = developers.id
AND extension_submissions.status = 'pending'
)
)`
)
.bind(developer.id, userId);

const deleteDeveloperStmt = this.db
.prepare(
`DELETE FROM developers
WHERE id = ?
AND owner_user_id = ?
AND NOT EXISTS (SELECT 1 FROM extensions WHERE extensions.author_id = developers.id)
AND NOT EXISTS (
SELECT 1 FROM extension_submissions
WHERE extension_submissions.developer_id = developers.id
AND extension_submissions.status = 'pending'
)`
)
.bind(developer.id, userId);

let results;
try {
results = (await this.db.batch([
deleteTransfersStmt,
deleteClaimsStmt,
deleteDeveloperStmt
])) as Array<{
success: boolean;
error?: string;
meta?: { changes?: number };
}>;
} catch (error) {
return databaseError("deleteOwn", error);
}

const failed = results.find((r) => !r.success);
if (failed) {
return databaseError(
"deleteOwn",
new Error(failed.error || "Database write failed")
);
}

const [, , developerResult] = results;
if (!developerResult.meta?.changes) {
return {
data: null,
error: await this.deletionBlockedError(developer.id, userId)
};
}

return { data: { id: developer.id, deleted: true }, error: null };
} catch (error) {
return databaseError("deleteOwn", error);
}
}

async getById(id: string): Promise<DatabaseResult<DeveloperProfile>> {
try {
const row = await this.db
Expand Down
59 changes: 59 additions & 0 deletions src/services/extensions/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,65 @@ extensionsV2.openapi(upsertOwnDeveloperRoute, async (c) => {
return c.json({ result: data }, 200);
});

const deleteOwnDeveloperRoute = createRoute({
method: "delete",
path: "/developers/me",
tags: ["Developers"],
summary: "Permanently delete the caller's own developer profile",
security: [{ Bearer: [] }],
middleware: [requireAuth()] as const,
responses: {
200: {
content: {
"application/json": {
schema: z.object({
result: z.object({ id: z.string(), deleted: z.literal(true) })
})
}
},
description: "Profile deleted"
},
401: {
content: { "application/json": { schema: ErrorResponseSchema } },
description: "Missing or invalid bearer token"
},
404: {
content: { "application/json": { schema: ErrorResponseSchema } },
description: "Caller has no developer profile"
},
409: {
content: { "application/json": { schema: ErrorResponseSchema } },
description:
"Profile still has published extensions, or has a pending submission awaiting review"
},
500: {
content: { "application/json": { schema: ErrorResponseSchema } },
description: "Database error"
}
}
});

extensionsV2.openapi(deleteOwnDeveloperRoute, async (c) => {
const auth = getAuth(c);
const platform = getPlatform(c);
const db = new DevelopersDatabase(platform.getDatabase("DB_EXTENSIONS"));

const { data, error } = await db.deleteOwn(auth.userId);
if (error || !data) {
return c.json(
{
error: {
message: error?.message ?? "Unable to delete developer profile",
code: error?.code ?? "DATABASE_ERROR"
}
},
statusFromErrorCode(error?.code)
);
}

return c.json({ result: data }, 200);
});

function statusFromOwnershipErrorCode(code?: string): 403 | 404 | 500 {
if (code === "NOT_FOUND") return 404;
if (code === "FORBIDDEN") return 403;
Expand Down
Loading
Loading