mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
refactor(backup): publish the target codes and keep the upload handler short
The target refusals answer with eight new meta.errorCode values; they are now constants the catalogue sweep reads and entries in the published catalogue, so a client can branch on them without triggering each one. The upload handler had grown past its size baseline with the target branch, so the target checks, the file read and the schema check move into named functions beside it, unchanged. The target check route holds its params to a strict schema like every other route.
This commit is contained in:
1 parent
b1bbffc69b
commit
8ed6eb5793
6 files changed
+232
-166
No files matched your search
+17
-14
@@ -38707,20 +38707,23 @@ components:
|
||||
auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required,
|
||||
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport,
|
||||
auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
|
||||
backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active,
|
||||
backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site —
|
||||
body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle,
|
||||
coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit,
|
||||
coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired,
|
||||
coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs —
|
||||
coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired,
|
||||
consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
|
||||
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
|
||||
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
|
||||
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
|
||||
disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich —
|
||||
dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected,
|
||||
dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
|
||||
backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing,
|
||||
backup.payload.undecryptable, backup.restore.active, backup.section.missing,
|
||||
backup.target.holds_records, backup.target.managed_profile, backup.target.not_found,
|
||||
backup.target.pending_deletion, backup.target.unconfirmed, backup.target.username_taken,
|
||||
backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach —
|
||||
coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound,
|
||||
coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound,
|
||||
coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable,
|
||||
coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json,
|
||||
coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle —
|
||||
cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
|
||||
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
|
||||
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
|
||||
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
|
||||
data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected,
|
||||
dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited,
|
||||
dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
|
||||
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
|
||||
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
|
||||
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
|
||||
|
||||
@@ -21,7 +21,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const paramsSchema = z.object({ userId: z.string().min(1).max(64) });
|
||||
const paramsSchema = z.object({ userId: z.string().min(1).max(64) }).strict();
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (
|
||||
|
||||
@@ -96,7 +96,10 @@ import type { BackupPayload } from "@/lib/validations/backup";
|
||||
import {
|
||||
BACKUP_OWNER_EXISTS_CODE,
|
||||
BACKUP_OWNER_MISSING_CODE,
|
||||
BACKUP_TARGET_UNCONFIRMED_CODE,
|
||||
BACKUP_TARGET_USERNAME_TAKEN_CODE,
|
||||
parseBackupUploadTarget,
|
||||
type BackupUploadTarget,
|
||||
} from "@/lib/validations/backup-upload-target";
|
||||
import {
|
||||
isCompatibleSchemaVersion,
|
||||
@@ -176,6 +179,176 @@ async function* decodedBytes(
|
||||
}
|
||||
}
|
||||
|
||||
/** A file's read failure as the refusal the operator sees. */
|
||||
function schemaRefusal(issues: Array<{ path: string; message: string }>) {
|
||||
return new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the file through the one streamed reader every backup reader shares
|
||||
* (`streamed-backup.ts`): the bulk sections an element at a time and the
|
||||
* documents without their stored bytes, so a document vault of hundreds of
|
||||
* megabytes is never in memory at once (#1239). It checks each measurement
|
||||
* and the canonical v2 id rule as it goes, and gathers the key ids the file's
|
||||
* inner ciphertext needs.
|
||||
*/
|
||||
async function readUploadedFile(
|
||||
source: () => AsyncIterable<Uint8Array>,
|
||||
): Promise<StreamedBackup> {
|
||||
try {
|
||||
return await readStreamedBackup(source);
|
||||
} catch (err) {
|
||||
if (err instanceof BackupJsonError) {
|
||||
throw new UploadRefused(422, "Uploaded file is not valid JSON", {
|
||||
reason: "invalid_json",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
if (err instanceof BackupOwnerRewriteError) {
|
||||
throw schemaRefusal([{ path: "userId", message: err.message }]);
|
||||
}
|
||||
if (err instanceof StreamedBackupInvalidError) {
|
||||
throw schemaRefusal([{ path: err.path, message: err.message }]);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The file against the backup schema, and its version against this server. */
|
||||
async function parseUploadedPayload(raw: unknown): Promise<BackupPayload> {
|
||||
// Loaded on first use, not at import (see `backup-summary.ts`).
|
||||
const { backupPayloadSchema } = await import("@/lib/validations/backup");
|
||||
let payload: BackupPayload;
|
||||
try {
|
||||
payload = backupPayloadSchema.parse(raw);
|
||||
} catch (err) {
|
||||
throw schemaRefusal(
|
||||
err instanceof ZodError
|
||||
? err.issues.slice(0, 10).map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
}
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
|
||||
{
|
||||
reason: "incompatible_schema_version",
|
||||
schemaVersion: payload.schemaVersion,
|
||||
},
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
type ChosenTarget = { id: string; username: string; created: boolean };
|
||||
|
||||
const usernameTaken = () =>
|
||||
new UploadRefused(
|
||||
409,
|
||||
"That username is already taken.",
|
||||
{ reason: "target_username_taken" },
|
||||
{ errorCode: BACKUP_TARGET_USERNAME_TAKEN_CODE },
|
||||
);
|
||||
|
||||
/**
|
||||
* The target the query names, checked before a byte of the file is read, so
|
||||
* a target that will be refused costs no upload. An existing account is
|
||||
* checked whole and confirmed by its username; a new one only for its
|
||||
* username, since it is created once the request is otherwise admissible.
|
||||
*/
|
||||
async function checkTargetBeforeRead(
|
||||
choice: BackupUploadTarget | null,
|
||||
): Promise<ChosenTarget | null> {
|
||||
if (choice?.into === "existing") {
|
||||
const verdict = await checkRestoreTarget(prisma, choice.userId);
|
||||
if (!verdict.ok) throw targetRefused(verdict, choice.userId);
|
||||
// The restore writes into this account, so the operator names it.
|
||||
if (choice.confirm.trim() !== verdict.target.username) {
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Type the chosen account's username to confirm. Nothing was stored.",
|
||||
{ reason: "target_unconfirmed", targetUserId: choice.userId },
|
||||
{ errorCode: BACKUP_TARGET_UNCONFIRMED_CODE },
|
||||
);
|
||||
}
|
||||
return { ...verdict.target, created: false };
|
||||
}
|
||||
if (choice?.into === "new") {
|
||||
const taken = await prisma.user.findUnique({
|
||||
where: { username: choice.username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (taken) throw usernameTaken();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account made for a copy. Created before the file is read, because the
|
||||
* file is rewritten to its id on the way in, and deleted again when nothing
|
||||
* is stored. No password, no email: nobody can sign into it until an admin
|
||||
* sets its first password (Admin, Users, Reset password).
|
||||
*/
|
||||
async function createTargetAccount(username: string): Promise<ChosenTarget> {
|
||||
try {
|
||||
const account = await prisma.user.create({
|
||||
data: { username, role: "USER" },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
return { ...account, created: true };
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
throw usernameTaken();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose copy a file sent with a target becomes, once it has been read. The
|
||||
* file now names the chosen account; the account it named before has to be
|
||||
* gone, because the copy of an account that is still here restores into that
|
||||
* account and never into another one. An existing target is checked again:
|
||||
* the file took a while to read, and the account may have changed meanwhile.
|
||||
*/
|
||||
async function ownerOfRetargetedFile(
|
||||
chosen: ChosenTarget,
|
||||
rewrite: BackupOwnerRewrite | null,
|
||||
): Promise<{ id: string; username: string }> {
|
||||
const declared = rewrite?.declaredOwner() ?? null;
|
||||
const declaredOwner = declared
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: declared },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (!declared || declaredOwner) {
|
||||
throw new UploadRefused(
|
||||
409,
|
||||
"This file belongs to an account on this server and restores into that account. Upload it without choosing an account.",
|
||||
{ reason: "owner_exists", ownerId: declared },
|
||||
{ errorCode: BACKUP_OWNER_EXISTS_CODE },
|
||||
);
|
||||
}
|
||||
if (!chosen.created) {
|
||||
const again = await checkRestoreTarget(prisma, chosen.id);
|
||||
if (!again.ok) throw targetRefused(again, chosen.id);
|
||||
}
|
||||
return { id: chosen.id, username: chosen.username };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the account an upload created for its copy, when nothing was stored.
|
||||
* It holds nothing and nobody can have signed into it (no password, no email),
|
||||
@@ -221,10 +394,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress,
|
||||
details: { reason, ...extra },
|
||||
});
|
||||
const refuse = async (err: UploadRefused) => {
|
||||
await denied(err.details.reason as string, err.details);
|
||||
return apiError(err.answer, err.status, err.meta);
|
||||
};
|
||||
|
||||
// The account the copy goes into when the file's own account is gone.
|
||||
// Decided before a byte of the file is read, so a target that will be
|
||||
// refused costs no upload; checked again once the file has been read.
|
||||
const parsedTarget = parseBackupUploadTarget(
|
||||
new URL(request.url).searchParams,
|
||||
);
|
||||
@@ -233,35 +408,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsedTarget.error);
|
||||
}
|
||||
const choice = parsedTarget.data;
|
||||
let target: { id: string; username: string; created: boolean } | null = null;
|
||||
if (choice?.into === "existing") {
|
||||
const verdict = await checkRestoreTarget(prisma, choice.userId);
|
||||
if (!verdict.ok) {
|
||||
const refusal = targetRefused(verdict, choice.userId);
|
||||
await denied(refusal.details.reason as string, refusal.details);
|
||||
return apiError(refusal.answer, refusal.status, refusal.meta);
|
||||
}
|
||||
// The restore writes into this account, so the operator names it.
|
||||
if (choice.confirm.trim() !== verdict.target.username) {
|
||||
await denied("target_unconfirmed", { targetUserId: choice.userId });
|
||||
return apiError(
|
||||
"Type the chosen account's username to confirm. Nothing was stored.",
|
||||
422,
|
||||
{ errorCode: "backup.target.unconfirmed" },
|
||||
);
|
||||
}
|
||||
target = { ...verdict.target, created: false };
|
||||
} else if (choice?.into === "new") {
|
||||
const taken = await prisma.user.findUnique({
|
||||
where: { username: choice.username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (taken) {
|
||||
await denied("target_username_taken");
|
||||
return apiError("That username is already taken.", 409, {
|
||||
errorCode: "backup.target.username_taken",
|
||||
});
|
||||
}
|
||||
let target: ChosenTarget | null;
|
||||
try {
|
||||
target = await checkTargetBeforeRead(choice);
|
||||
} catch (err) {
|
||||
if (err instanceof UploadRefused) return refuse(err);
|
||||
throw err;
|
||||
}
|
||||
annotate({ meta: { backup_upload_into: choice?.into ?? "owner" } });
|
||||
|
||||
@@ -338,26 +490,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let created: { id: string };
|
||||
let rewrite: BackupOwnerRewrite | null = null;
|
||||
if (choice?.into === "new") {
|
||||
// Created before the file is read, because the file is rewritten to the
|
||||
// account's id on its way in; deleted again below when nothing is
|
||||
// stored. No password, no email: the account cannot be signed into
|
||||
// until an admin sets its first password (Admin, Users).
|
||||
try {
|
||||
const account = await prisma.user.create({
|
||||
data: { username: choice.username, role: "USER" },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
target = { ...account, created: true };
|
||||
target = await createTargetAccount(choice.username);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
await denied("target_username_taken");
|
||||
return apiError("That username is already taken.", 409, {
|
||||
errorCode: "backup.target.username_taken",
|
||||
});
|
||||
}
|
||||
if (err instanceof UploadRefused) return refuse(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -391,75 +527,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
// Read through the one streamed reader every backup reader shares
|
||||
// (`streamed-backup.ts`): the bulk sections an element at a time and
|
||||
// the documents without their stored bytes, so a document vault of
|
||||
// hundreds of megabytes is never in memory at once (#1239). It checks
|
||||
// each measurement and the canonical v2 id rule as it goes, and
|
||||
// gathers the key ids the file's inner ciphertext needs.
|
||||
let streamed: StreamedBackup;
|
||||
try {
|
||||
streamed = await readStreamedBackup(storedAsRead);
|
||||
} catch (err) {
|
||||
if (err instanceof BackupJsonError) {
|
||||
throw new UploadRefused(422, "Uploaded file is not valid JSON", {
|
||||
reason: "invalid_json",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
if (err instanceof BackupOwnerRewriteError) {
|
||||
const issues = [{ path: "userId", message: err.message }];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
if (err instanceof StreamedBackupInvalidError) {
|
||||
const issues = [{ path: err.path, message: err.message }];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const streamed = await readUploadedFile(storedAsRead);
|
||||
measurementCount = streamed.measurementCount;
|
||||
|
||||
// Loaded on first use, not at import (see `backup-summary.ts`).
|
||||
const { backupPayloadSchema } =
|
||||
await import("@/lib/validations/backup");
|
||||
try {
|
||||
payload = backupPayloadSchema.parse(streamed.raw);
|
||||
} catch (err) {
|
||||
const issues =
|
||||
err instanceof ZodError
|
||||
? err.issues.slice(0, 10).map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
}))
|
||||
: [];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
`Backup schema version '${payload.schemaVersion}' is not supported by this server`,
|
||||
{
|
||||
reason: "incompatible_schema_version",
|
||||
schemaVersion: payload.schemaVersion,
|
||||
},
|
||||
);
|
||||
}
|
||||
payload = await parseUploadedPayload(streamed.raw);
|
||||
|
||||
// A file whose inner ciphertext needs a key this host does not have
|
||||
// can be stored but never restored: the restore would refuse it, or
|
||||
@@ -483,30 +553,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
if (chosen) {
|
||||
// The file now names the chosen account. The account it named
|
||||
// before has to be gone: a copy of an account that is still here
|
||||
// restores into that account, never into another one.
|
||||
const declared = rewrite?.declaredOwner() ?? null;
|
||||
const declaredOwner = declared
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: declared },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (!declared || declaredOwner) {
|
||||
throw new UploadRefused(
|
||||
409,
|
||||
"This file belongs to an account on this server and restores into that account. Upload it without choosing an account.",
|
||||
{ reason: "owner_exists", ownerId: declared },
|
||||
{ errorCode: BACKUP_OWNER_EXISTS_CODE },
|
||||
);
|
||||
}
|
||||
// The file took a while to read; the account may have changed.
|
||||
if (!chosen.created) {
|
||||
const again = await checkRestoreTarget(prisma, chosen.id);
|
||||
if (!again.ok) throw targetRefused(again, chosen.id);
|
||||
}
|
||||
owner = { id: chosen.id, username: chosen.username };
|
||||
owner = await ownerOfRetargetedFile(chosen, rewrite);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -534,10 +581,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
} catch (err) {
|
||||
// Nothing was stored, so the account made for the copy goes again.
|
||||
if (chosen?.created) await discardCreatedAccount(chosen.id);
|
||||
if (err instanceof UploadRefused) {
|
||||
await denied(err.details.reason as string, err.details);
|
||||
return apiError(err.answer, err.status, err.meta);
|
||||
}
|
||||
if (err instanceof UploadRefused) return refuse(err);
|
||||
// The file was fine; its encrypted copy is larger than the stored-copy
|
||||
// limit (`BACKUP_MAX_STORED_MB`). That is the operator's to act on, with
|
||||
// the numbers and the setting in the message, and not a server fault for
|
||||
@@ -581,8 +625,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
backupId: created.id,
|
||||
ownerId: storedOwner?.id,
|
||||
ownerUsername: storedOwner?.username,
|
||||
// Set when the file's own account was gone and the copy was pointed at
|
||||
// this one. The erased account's id is not recorded.
|
||||
// Set when the copy went into a chosen account; the erased id is not.
|
||||
...(chosen ? { into: chosen.created ? "new" : "existing" } : {}),
|
||||
type: uploadType,
|
||||
schemaVersion: summary.schemaVersion,
|
||||
|
||||
@@ -125,6 +125,14 @@ export async function checkRestoreTarget(
|
||||
return { ok: true, target };
|
||||
}
|
||||
|
||||
/** The wire code of each refusal. */
|
||||
export const BACKUP_TARGET_REFUSAL_CODES = {
|
||||
not_found: "backup.target.not_found",
|
||||
managed_profile: "backup.target.managed_profile",
|
||||
pending_deletion: "backup.target.pending_deletion",
|
||||
holds_records: "backup.target.holds_records",
|
||||
} as const satisfies Record<RestoreTargetRefusal, string>;
|
||||
|
||||
/** The status and stable code each refusal answers with. */
|
||||
export const RESTORE_TARGET_REFUSALS: Readonly<
|
||||
Record<
|
||||
@@ -134,24 +142,24 @@ export const RESTORE_TARGET_REFUSALS: Readonly<
|
||||
> = {
|
||||
not_found: {
|
||||
status: 404,
|
||||
code: "backup.target.not_found",
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.not_found,
|
||||
message: "The chosen account does not exist.",
|
||||
},
|
||||
managed_profile: {
|
||||
status: 409,
|
||||
code: "backup.target.managed_profile",
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.managed_profile,
|
||||
message:
|
||||
"The chosen account is a managed profile. Its Guardians run it, so a copy cannot be restored into it.",
|
||||
},
|
||||
pending_deletion: {
|
||||
status: 409,
|
||||
code: "backup.target.pending_deletion",
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.pending_deletion,
|
||||
message:
|
||||
"The chosen account is pending deletion. Undo the deletion first, or choose another account.",
|
||||
},
|
||||
holds_records: {
|
||||
status: 409,
|
||||
code: "backup.target.holds_records",
|
||||
code: BACKUP_TARGET_REFUSAL_CODES.holds_records,
|
||||
message:
|
||||
"The chosen account already holds records, and a restore replaces them rather than adding to them. Choose an empty account or create a new one.",
|
||||
},
|
||||
|
||||
@@ -100,9 +100,17 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
backup: [
|
||||
"backup.foreign_reference",
|
||||
"backup.key.missing",
|
||||
"backup.owner.exists",
|
||||
"backup.owner.missing",
|
||||
"backup.payload.undecryptable",
|
||||
"backup.restore.active",
|
||||
"backup.section.missing",
|
||||
"backup.target.holds_records",
|
||||
"backup.target.managed_profile",
|
||||
"backup.target.not_found",
|
||||
"backup.target.pending_deletion",
|
||||
"backup.target.unconfirmed",
|
||||
"backup.target.username_taken",
|
||||
"backup.upload.too_large",
|
||||
],
|
||||
biomarkers: ["biomarkers.unit.locked"],
|
||||
|
||||
@@ -18,6 +18,10 @@ import { accountUsernameSchema } from "@/lib/validations/auth";
|
||||
export const BACKUP_OWNER_MISSING_CODE = "backup.owner.missing";
|
||||
/** The upload's refusal when a target was chosen for a file whose account exists. */
|
||||
export const BACKUP_OWNER_EXISTS_CODE = "backup.owner.exists";
|
||||
/** The typed username does not match the chosen account. */
|
||||
export const BACKUP_TARGET_UNCONFIRMED_CODE = "backup.target.unconfirmed";
|
||||
/** The username asked for a new account is taken. */
|
||||
export const BACKUP_TARGET_USERNAME_TAKEN_CODE = "backup.target.username_taken";
|
||||
|
||||
export const backupUploadTargetSchema = z.discriminatedUnion("into", [
|
||||
z
|
||||
|
||||
Reference in new issue
Block a user