fix(housekeeping): harden preference persistence
This commit is contained in:
1 parent
64bb230de5
commit
4e0bf598ed
7 files changed
+271
-81
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -1,42 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { authorizeHousekeeping } from "@/features/housekeeping/foundation/authorization";
|
||||
import {
|
||||
anyCapability,
|
||||
fail,
|
||||
type HousekeepingCapabilityContext,
|
||||
type HousekeepingResult,
|
||||
ok,
|
||||
} from "@/features/housekeeping/foundation/contracts";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
|
||||
import { reconcilePreferences } from "@/features/housekeeping/foundation/preferences/reconcile";
|
||||
import type { HousekeepingPreferencesRepository } from "@/features/housekeeping/foundation/preferences/repository";
|
||||
import {
|
||||
type HousekeepingPreferences,
|
||||
housekeepingPreferencesSchema,
|
||||
} from "@/features/housekeeping/foundation/preferences/schema";
|
||||
import type { HousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||
import { housekeepingPreferencesRepository } from "@/lib/housekeeping-preferences-repository";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
|
||||
const preferencesCapability = anyCapability(PERMS.ADMIN_DASHBOARD);
|
||||
const housekeepingRegistry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||
|
||||
export interface HousekeepingPreferencesActionDependencies {
|
||||
repository: HousekeepingPreferencesRepository;
|
||||
registry: HousekeepingRegistry;
|
||||
getContext?: () => Promise<HousekeepingCapabilityContext>;
|
||||
}
|
||||
|
||||
export async function loadHousekeepingPreferences(
|
||||
dependencies: HousekeepingPreferencesActionDependencies,
|
||||
): Promise<HousekeepingResult<HousekeepingPreferences>> {
|
||||
const context = await resolveContext(dependencies);
|
||||
export async function loadHousekeepingPreferences(): Promise<
|
||||
HousekeepingResult<HousekeepingPreferences>
|
||||
> {
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
const authorization = authorizeHousekeeping(context, preferencesCapability);
|
||||
if (!authorization.ok) return authorization;
|
||||
|
||||
const correlationId = createCorrelationId();
|
||||
try {
|
||||
const stored = await dependencies.repository.read(context.actor.id);
|
||||
const stored = await housekeepingPreferencesRepository.read(
|
||||
context.actor.id,
|
||||
);
|
||||
return ok(
|
||||
reconcilePreferences(stored, dependencies.registry, context),
|
||||
reconcilePreferences(stored, housekeepingRegistry, context),
|
||||
correlationId,
|
||||
);
|
||||
} catch {
|
||||
@@ -50,9 +49,8 @@ export async function loadHousekeepingPreferences(
|
||||
|
||||
export async function saveHousekeepingPreferences(
|
||||
input: unknown,
|
||||
dependencies: HousekeepingPreferencesActionDependencies,
|
||||
): Promise<HousekeepingResult<HousekeepingPreferences>> {
|
||||
const context = await resolveContext(dependencies);
|
||||
const context = await getHousekeepingCapabilityContext();
|
||||
const authorization = authorizeHousekeeping(context, preferencesCapability);
|
||||
if (!authorization.ok) return authorization;
|
||||
|
||||
@@ -66,9 +64,17 @@ export async function saveHousekeepingPreferences(
|
||||
);
|
||||
}
|
||||
|
||||
const reconciled = reconcilePreferences(
|
||||
parsed.data,
|
||||
housekeepingRegistry,
|
||||
context,
|
||||
);
|
||||
try {
|
||||
await dependencies.repository.upsert(context.actor.id, parsed.data);
|
||||
return ok(parsed.data, correlationId);
|
||||
await housekeepingPreferencesRepository.upsert(
|
||||
context.actor.id,
|
||||
reconciled,
|
||||
);
|
||||
return ok(reconciled, correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"INTERNAL",
|
||||
@@ -77,11 +83,3 @@ export async function saveHousekeepingPreferences(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveContext(
|
||||
dependencies: HousekeepingPreferencesActionDependencies,
|
||||
): Promise<HousekeepingCapabilityContext> {
|
||||
return dependencies.getContext
|
||||
? dependencies.getContext()
|
||||
: getHousekeepingCapabilityContext();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -32,11 +32,15 @@ export function createHousekeepingPreferencesRepository(
|
||||
async read(userId) {
|
||||
const row = await storage.findByUserId(userId);
|
||||
if (!row) return defaultHousekeepingPreferences();
|
||||
if (row.schemaVersion !== 1) {
|
||||
throw new Error("unsupported housekeeping preferences schema version");
|
||||
}
|
||||
|
||||
try {
|
||||
if (row.schemaVersion !== 1) {
|
||||
return defaultHousekeepingPreferences();
|
||||
}
|
||||
return parseHousekeepingPreferences(row.payload);
|
||||
} catch {
|
||||
return defaultHousekeepingPreferences();
|
||||
}
|
||||
},
|
||||
async upsert(userId, value) {
|
||||
await storage.upsert({
|
||||
|
||||
@@ -43,4 +43,32 @@ describe("housekeeping preferences schema", () => {
|
||||
|
||||
expect(parseHousekeepingPreferences(JSON.stringify(value))).toEqual(value);
|
||||
});
|
||||
|
||||
it("rejects identifiers and lists that exceed the bounded presentation payload", () => {
|
||||
const value = defaultHousekeepingPreferences();
|
||||
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({
|
||||
...value,
|
||||
pinnedRouteIds: ["a".repeat(129)],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({
|
||||
...value,
|
||||
pinnedRouteIds: Array.from(
|
||||
{ length: 65 },
|
||||
(_, index) => `route.${index}`,
|
||||
),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
housekeepingPreferencesSchema.safeParse({
|
||||
...value,
|
||||
pinnedRouteIds: Array.from({ length: 33 }, (_, index) =>
|
||||
`${index}`.padEnd(128, "a"),
|
||||
),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const HOUSEKEEPING_PREFERENCE_MAX_IDENTIFIER_LENGTH = 128;
|
||||
export const HOUSEKEEPING_PREFERENCE_MAX_ITEMS_PER_ARRAY = 64;
|
||||
export const HOUSEKEEPING_PREFERENCE_MAX_SERIALIZED_BYTES = 4096;
|
||||
|
||||
export interface HousekeepingPreferences {
|
||||
schemaVersion: 1;
|
||||
pinnedRouteIds: string[];
|
||||
@@ -10,7 +14,10 @@ export interface HousekeepingPreferences {
|
||||
}
|
||||
|
||||
const uniqueIdentifiers = z
|
||||
.array(z.string().trim().min(1))
|
||||
.array(
|
||||
z.string().trim().min(1).max(HOUSEKEEPING_PREFERENCE_MAX_IDENTIFIER_LENGTH),
|
||||
)
|
||||
.max(HOUSEKEEPING_PREFERENCE_MAX_ITEMS_PER_ARRAY)
|
||||
.superRefine((values, context) => {
|
||||
const seen = new Set<string>();
|
||||
for (const [index, value] of values.entries()) {
|
||||
@@ -35,7 +42,18 @@ export const housekeepingPreferencesSchema: z.ZodType<HousekeepingPreferences> =
|
||||
widgetOrder: uniqueIdentifiers,
|
||||
enabledOptionalWidgetIds: uniqueIdentifiers,
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.superRefine((preferences, context) => {
|
||||
if (
|
||||
JSON.stringify(preferences).length >
|
||||
HOUSEKEEPING_PREFERENCE_MAX_SERIALIZED_BYTES
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "housekeeping preferences payload is too large",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function defaultHousekeepingPreferences(): HousekeepingPreferences {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createHousekeepingPreferencesRepository,
|
||||
type HousekeepingPreferencesStorage,
|
||||
} from "@/features/housekeeping/foundation/preferences/repository";
|
||||
import { type Db, db, HousekeepingUserPreferences } from "@/lib/db";
|
||||
|
||||
type HousekeepingPreferencesDb = Pick<Db, "select" | "insert">;
|
||||
|
||||
export function createDrizzleHousekeepingPreferencesStorage(
|
||||
database: HousekeepingPreferencesDb,
|
||||
): HousekeepingPreferencesStorage {
|
||||
return {
|
||||
async findByUserId(userId) {
|
||||
const rows = await database
|
||||
.select({
|
||||
schemaVersion: HousekeepingUserPreferences.schemaVersion,
|
||||
payload: HousekeepingUserPreferences.payload,
|
||||
})
|
||||
.from(HousekeepingUserPreferences)
|
||||
.where(eq(HousekeepingUserPreferences.userId, userId))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
async upsert(row) {
|
||||
await database
|
||||
.insert(HousekeepingUserPreferences)
|
||||
.values(row)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
schemaVersion: row.schemaVersion,
|
||||
payload: row.payload,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const housekeepingPreferencesRepository =
|
||||
createHousekeepingPreferencesRepository(
|
||||
createDrizzleHousekeepingPreferencesStorage(db),
|
||||
);
|
||||
Reference in new issue
Block a user