Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add optional validateAgainstSchema option when creating user storage entry paths #5326

Merged
merged 2 commits into from
Feb 14, 2025
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
60 changes: 35 additions & 25 deletions packages/profile-sync-controller/src/sdk/user-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { SHARED_SALT } from '../shared/encryption/constants';
import type { Env } from '../shared/env';
import { getEnvUrls } from '../shared/env';
import type {
UserStorageFeatureKeys,
UserStorageFeatureNames,
UserStoragePathWithFeatureAndKey,
UserStoragePathWithFeatureOnly,
UserStorageGenericFeatureKey,
UserStorageGenericFeatureName,
UserStorageGenericPathWithFeatureAndKey,
UserStorageGenericPathWithFeatureOnly,
} from '../shared/storage-schema';
import { createEntryPath } from '../shared/storage-schema';

Expand Down Expand Up @@ -54,42 +54,46 @@ export class UserStorage {
}

async setItem(
path: UserStoragePathWithFeatureAndKey,
path: UserStorageGenericPathWithFeatureAndKey,
value: string,
): Promise<void> {
await this.#upsertUserStorage(path, value);
}

async batchSetItems<FeatureName extends UserStorageFeatureNames>(
path: FeatureName,
values: [UserStorageFeatureKeys<FeatureName>, string][],
async batchSetItems(
path: UserStorageGenericFeatureName,
values: [UserStorageGenericFeatureKey, string][],
) {
await this.#batchUpsertUserStorage(path, values);
}

async getItem(path: UserStoragePathWithFeatureAndKey): Promise<string> {
async getItem(
path: UserStorageGenericPathWithFeatureAndKey,
): Promise<string> {
return this.#getUserStorage(path);
}

async getAllFeatureItems(
path: UserStoragePathWithFeatureOnly,
path: UserStorageGenericFeatureName,
): Promise<string[] | null> {
return this.#getUserStorageAllFeatureEntries(path);
}

async deleteItem(path: UserStoragePathWithFeatureAndKey): Promise<void> {
async deleteItem(
path: UserStorageGenericPathWithFeatureAndKey,
): Promise<void> {
return this.#deleteUserStorage(path);
}

async deleteAllFeatureItems(
path: UserStoragePathWithFeatureOnly,
path: UserStorageGenericFeatureName,
): Promise<void> {
return this.#deleteUserStorageAllFeatureEntries(path);
}

async batchDeleteItems(
path: UserStoragePathWithFeatureOnly,
values: string[],
path: UserStorageGenericFeatureName,
values: UserStorageGenericFeatureKey[],
) {
return this.#batchDeleteUserStorage(path, values);
}
Expand All @@ -110,14 +114,16 @@ export class UserStorage {
}

async #upsertUserStorage(
path: UserStoragePathWithFeatureAndKey,
path: UserStorageGenericPathWithFeatureAndKey,
data: string,
): Promise<void> {
try {
const headers = await this.#getAuthorizationHeader();
const storageKey = await this.getStorageKey();
const encryptedData = await encryption.encryptString(data, storageKey);
const encryptedPath = createEntryPath(path, storageKey);
const encryptedPath = createEntryPath(path, storageKey, {
validateAgainstSchema: false,
});

const url = new URL(STORAGE_URL(this.env, encryptedPath));

Expand Down Expand Up @@ -150,7 +156,7 @@ export class UserStorage {
}

async #batchUpsertUserStorage(
path: UserStoragePathWithFeatureOnly,
path: UserStorageGenericPathWithFeatureOnly,
data: [string, string][],
): Promise<void> {
try {
Expand Down Expand Up @@ -201,7 +207,7 @@ export class UserStorage {
}

async #batchUpsertUserStorageWithAlreadyHashedAndEncryptedEntries(
path: UserStoragePathWithFeatureOnly,
path: UserStorageGenericPathWithFeatureOnly,
encryptedData: [string, string][],
): Promise<void> {
try {
Expand Down Expand Up @@ -242,12 +248,14 @@ export class UserStorage {
}

async #getUserStorage(
path: UserStoragePathWithFeatureAndKey,
path: UserStorageGenericPathWithFeatureAndKey,
): Promise<string> {
try {
const headers = await this.#getAuthorizationHeader();
const storageKey = await this.getStorageKey();
const encryptedPath = createEntryPath(path, storageKey);
const encryptedPath = createEntryPath(path, storageKey, {
validateAgainstSchema: false,
});

const url = new URL(STORAGE_URL(this.env, encryptedPath));

Expand Down Expand Up @@ -300,7 +308,7 @@ export class UserStorage {
}

async #getUserStorageAllFeatureEntries(
path: UserStoragePathWithFeatureOnly,
path: UserStorageGenericPathWithFeatureOnly,
): Promise<string[] | null> {
try {
const headers = await this.#getAuthorizationHeader();
Expand Down Expand Up @@ -383,12 +391,14 @@ export class UserStorage {
}

async #deleteUserStorage(
path: UserStoragePathWithFeatureAndKey,
path: UserStorageGenericPathWithFeatureAndKey,
): Promise<void> {
try {
const headers = await this.#getAuthorizationHeader();
const storageKey = await this.getStorageKey();
const encryptedPath = createEntryPath(path, storageKey);
const encryptedPath = createEntryPath(path, storageKey, {
validateAgainstSchema: false,
});

const url = new URL(STORAGE_URL(this.env, encryptedPath));

Expand Down Expand Up @@ -428,7 +438,7 @@ export class UserStorage {
}

async #deleteUserStorageAllFeatureEntries(
path: UserStoragePathWithFeatureOnly,
path: UserStorageGenericPathWithFeatureOnly,
): Promise<void> {
try {
const headers = await this.#getAuthorizationHeader();
Expand Down Expand Up @@ -469,7 +479,7 @@ export class UserStorage {
}

async #batchDeleteUserStorage(
path: UserStoragePathWithFeatureOnly,
path: UserStorageGenericPathWithFeatureOnly,
data: string[],
): Promise<void> {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ describe('user-storage/schema.ts', () => {
);
});

it('should not throw errors if validateAgainstSchema is false', () => {
const path = 'invalid.feature';
expect(() =>
getFeatureAndKeyFromPath(path, {
validateAgainstSchema: false,
}),
).not.toThrow();
});

it('should return feature and key from path', () => {
const result = getFeatureAndKeyFromPath(
`${USER_STORAGE_FEATURE_NAMES.notifications}.notification_settings`,
Expand All @@ -68,5 +77,15 @@ describe('user-storage/schema.ts', () => {
key: '0x123',
});
});

it('should return feature and key from path with arbitrary feature and key when validateAgainstSchema is false', () => {
const result = getFeatureAndKeyFromPath('feature.key', {
validateAgainstSchema: false,
});
expect(result).toStrictEqual({
feature: 'feature',
key: 'key',
});
});
});
});
101 changes: 74 additions & 27 deletions packages/profile-sync-controller/src/shared/storage-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,36 @@ export type UserStoragePathWithFeatureAndKey = {
[K in UserStorageFeatureNames]: `${K}.${UserStorageFeatureKeys<K>}`;
}[UserStoragePathWithFeatureOnly];

export const getFeatureAndKeyFromPath = (
path: UserStoragePathWithFeatureAndKey,
): UserStorageFeatureAndKey => {
/**
* The below types are mainly used for the SDK.
* These exist so that the SDK can be used with arbitrary feature names and keys.
*
* We only type enforce feature names and keys when using UserStorageController.
* This is done so we don't end up with magic strings within the applications.
*/

export type UserStorageGenericFeatureName = string;
export type UserStorageGenericFeatureKey = string;
export type UserStorageGenericPathWithFeatureAndKey =
`${UserStorageGenericFeatureName}.${UserStorageGenericFeatureKey}`;
export type UserStorageGenericPathWithFeatureOnly =
UserStorageGenericFeatureName;

type UserStorageGenericFeatureAndKey = {
feature: UserStorageGenericFeatureName;
key: UserStorageGenericFeatureKey;
};

export const getFeatureAndKeyFromPath = <T extends boolean>(
path: T extends true
? UserStoragePathWithFeatureAndKey
: UserStorageGenericPathWithFeatureAndKey,
options: {
validateAgainstSchema: T;
} = { validateAgainstSchema: true as T },
): T extends true
? UserStorageFeatureAndKey
: UserStorageGenericFeatureAndKey => {
const pathRegex = /^\w+\.\w+$/u;

if (!pathRegex.test(path)) {
Expand All @@ -52,29 +79,41 @@ export const getFeatureAndKeyFromPath = (
);
}

const [feature, key] = path.split('.') as [
UserStorageFeatureNames,
UserStorageFeatureKeys<UserStorageFeatureNames>,
];

if (!(feature in USER_STORAGE_SCHEMA)) {
throw new Error(`user-storage - invalid feature provided: ${feature}`);
}

const validFeature = USER_STORAGE_SCHEMA[feature] as readonly string[];

if (
!validFeature.includes(key) &&
!validFeature.includes(ALLOW_ARBITRARY_KEYS)
) {
const validKeys = USER_STORAGE_SCHEMA[feature].join(', ');

throw new Error(
`user-storage - invalid key provided for this feature: ${key}. Valid keys: ${validKeys}`,
);
const [feature, key] = path.split('.');

if (options.validateAgainstSchema) {
const featureToValidate = feature as UserStorageFeatureNames;
const keyToValidate = key as UserStorageFeatureKeys<
typeof featureToValidate
>;

if (!(featureToValidate in USER_STORAGE_SCHEMA)) {
throw new Error(
`user-storage - invalid feature provided: ${featureToValidate}. Valid features: ${Object.keys(
USER_STORAGE_SCHEMA,
).join(', ')}`,
);
}

const validFeature = USER_STORAGE_SCHEMA[
featureToValidate
] as readonly string[];

if (
!validFeature.includes(keyToValidate) &&
!validFeature.includes(ALLOW_ARBITRARY_KEYS)
) {
const validKeys = USER_STORAGE_SCHEMA[featureToValidate].join(', ');

throw new Error(
`user-storage - invalid key provided for this feature: ${keyToValidate}. Valid keys: ${validKeys}`,
);
}
}

return { feature, key };
return { feature, key } as T extends true
? UserStorageFeatureAndKey
: UserStorageGenericFeatureAndKey;
};

export const isPathWithFeatureAndKey = (
Expand All @@ -92,13 +131,21 @@ export const isPathWithFeatureAndKey = (
*
* @param path - string in the form of `${feature}.${key}` that matches schema
* @param storageKey - users storage key
* @param options - options object
* @param options.validateAgainstSchema - whether to validate the path against the schema.
* This defaults to true, and should only be set to false when using the SDK with arbitrary feature names and keys.
* @returns path to store entry
*/
export function createEntryPath(
path: UserStoragePathWithFeatureAndKey,
export function createEntryPath<T extends boolean>(
path: T extends true
? UserStoragePathWithFeatureAndKey
: UserStorageGenericPathWithFeatureAndKey,
storageKey: string,
options: {
validateAgainstSchema: T;
} = { validateAgainstSchema: true as T },
): string {
const { feature, key } = getFeatureAndKeyFromPath(path);
const { feature, key } = getFeatureAndKeyFromPath(path, options);
const hashedKey = createSHA256Hash(key + storageKey);

return `${feature}/${hashedKey}`;
Expand Down
Loading