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

Identity Metadata Repository setup #2520

Merged
merged 11 commits into from
Jul 2, 2024
168 changes: 168 additions & 0 deletions src/frontend/src/repositories/anchorMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { MetadataMapV2 } from "$generated/internet_identity_types";
import type { AuthenticatedConnection } from "$src/utils/iiConnection";

export type AnchorMetadata = {
recoveryPageShownTimestampMillis?: number;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this need to be bigint?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will get it from the browser with Date.now() which returns a number.

From chatGPT:

Date.now() will return Number.MAX_SAFE_INTEGER sometime around the year 287,586

If we were using nanoseconds, then yes. But with milliseconds it should be enough.

};

const RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS = "recoveryPageShownTimestampMillis";
const METADATA_FETCH_TIMEOUT = 1_000;
const METADATA_FETCH_RETRIES = 10;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 seems like a lot, no?


const convertMetadata = (rawMetadata: MetadataMapV2): AnchorMetadata => {
const recoveryPageEntry = rawMetadata.find(
([key]) => key === RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS
);
if (recoveryPageEntry === undefined) {
return {};
}
const stringValue = recoveryPageEntry[1];
const recoveryPageShownTimestampMillis = Number(stringValue);
if (isNaN(recoveryPageShownTimestampMillis)) {
return {};
}
return {
recoveryPageShownTimestampMillis,
};
};

export class MetadataNotLoadedError extends Error {}
export class MetadataNotCommittedError extends Error {}

export class AnchorMetadataRepository {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quite a while ago we decided to sunset the term "anchor" because it is confusing. So on the UI, this term is no longer used. In the code, we didn't change things.
But for new code, we now use "identity" rather than anchor. Hence, why on the API level it is called IdentityMetadata rather than AnchorMetadata.
I think it would be better to use the new terminology here too and keep going with the slow journey of eventually replacing all the occurrences of "anchor".

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

// The nice AnchorMetadata is exposed to the outside world, while the raw metadata is kept private.
// We keep all the raw data to maintain other metadata fields.
private rawMetadata: MetadataMapV2 | "loading" | "error" | "not-loaded";
// Flag to keep track whether we need to commit the metadata to the canister.
private updatedMetadata: boolean;

constructor(private connection: AuthenticatedConnection) {
this.rawMetadata = "not-loaded";
this.updatedMetadata = false;
// Load the metadata in the background.
void this.loadMetadata();
}

/**
* Load the metadata in the instance variable `rawMetadata`.
*
* This method won't throw an error if the metadata can't be loaded.
* Instead, it will set the instance variable `rawMetadata` to "error".
*
* @returns {Promise<void>} In case a client wants to wait for the metadata to be loaded.
*/
loadMetadata = async (): Promise<void> => {
this.rawMetadata = "loading";
const identityInfo = await this.connection.getIdentityInfo();
if ("Ok" in identityInfo) {
this.updatedMetadata = false;
this.rawMetadata = identityInfo.Ok.metadata;
} else {
this.rawMetadata = "error";
}
};

/**
* It waits until the metadata is loaded and then converts it to a nice AnchorMetadata object.
*
* @throws {MetadataNotLoadedError} If the metadata is not yet loaded after METADATA_FETCH_TIMEOUT * METADATA_FETCH_RETRIES milliseconds.
* or has failed after METADATA_FETCH_RETRIES.
* @returns {AnchorMetadata}
*/
getMetadata = async (): Promise<AnchorMetadata> => {
// Wait for the metadata to load.
for (let i = 0; i < METADATA_FETCH_RETRIES; i++) {
if (this.rawMetadata === "loading") {
await new Promise((resolve) =>
setTimeout(resolve, METADATA_FETCH_TIMEOUT)
);
} else if (
this.rawMetadata === "error" ||
this.rawMetadata === "not-loaded"
) {
// Retry in case of error or not loaded.
void this.loadMetadata();
} else {
break;
}
}
if (

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest to extract a helper for this condition.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense

this.rawMetadata === "loading" ||
this.rawMetadata === "error" ||
this.rawMetadata === "not-loaded"
) {
throw new MetadataNotLoadedError("Metadata couldn't be loaded.");
}
return convertMetadata(this.rawMetadata);
};

/**
* Changes the metadata in memory but doesn't commit it to the canister.
*
* The metadata passed will be merged with the existing metadata. Same keys will be overwritten.
*
* If the metadata is not loaded yet, it will try to load it
* If loading the metadata fails, it will throw an error.
*
* @param {Partial<AnchorMetadata>} partialMetadata
* @throws {MetadataNotLoadedError} If the metadata can't be loaded.
* @returns {Promise<void>} To indicate that the metadata has been set.
*/
setPartialMetadata = async (
partialMetadata: Partial<AnchorMetadata>
): Promise<void> => {
await this.getMetadata();
if (
this.rawMetadata === "loading" ||
this.rawMetadata === "error" ||
this.rawMetadata === "not-loaded"
) {
throw new MetadataNotLoadedError("Metadata couldn't be loaded.");
}
let updatedMetadata: MetadataMapV2 = [...this.rawMetadata];
if (partialMetadata.recoveryPageShownTimestampMillis !== undefined) {
this.updatedMetadata = true;
updatedMetadata = updatedMetadata
.filter(([key]) => key !== RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS)
.concat([
[
RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS,
{
String:
partialMetadata.recoveryPageShownTimestampMillis.toString(),
},
],
]);
}
this.rawMetadata = updatedMetadata;
};

/**
* Commits the metadata to the canister if needed.
*
* @throws {MetadataNotLoadedError} If the metadata can't be committed because it's not loaded yet or there was an error.
* @throws {MetadataNotCommittedError} If the metadata can't be committed because there was an error connecting with the canister.
* @returns {boolean} Whether the metadata was committed.
*/
commitMetadata = async (): Promise<boolean> => {
if (
this.rawMetadata === "loading" ||
this.rawMetadata === "error" ||
this.rawMetadata === "not-loaded"
) {
throw new MetadataNotLoadedError("Metadata couldn't be loaded.");
}
if (this.updatedMetadata) {
const response = await this.connection.setIdentityMetadata(
this.rawMetadata
);
if ("Ok" in response) {
this.updatedMetadata = false;
return true;
}
throw new MetadataNotCommittedError(JSON.stringify(response.Err));
}
// If there was nothing to commit, we return true.
return true;
};
}
38 changes: 38 additions & 0 deletions src/frontend/src/utils/iiConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import {
GetDelegationResponse,
IdAliasCredentials,
IdentityAnchorInfo,
IdentityInfo,
IdentityInfoError,
IdentityMetadataReplaceError,
KeyType,
MetadataMapV2,
PreparedIdAlias,
PublicKey,
Purpose,
Expand All @@ -28,6 +32,10 @@ import {
} from "$generated/internet_identity_types";
import { fromMnemonicWithoutValidation } from "$src/crypto/ed25519";
import { features } from "$src/features";
import {
AnchorMetadata,
AnchorMetadataRepository,
} from "$src/repositories/anchorMetadata";
import { diagnosticInfo, unknownToString } from "$src/utils/utils";
import {
Actor,
Expand Down Expand Up @@ -411,6 +419,7 @@ export class Connection {
}

export class AuthenticatedConnection extends Connection {
private metadataRepository: AnchorMetadataRepository;
public constructor(
public canisterId: string,
public identity: SignIdentity,
Expand All @@ -419,6 +428,7 @@ export class AuthenticatedConnection extends Connection {
public actor?: ActorSubclass<_SERVICE>
) {
super(canisterId);
this.metadataRepository = new AnchorMetadataRepository(this);
}

async getActor(): Promise<ActorSubclass<_SERVICE>> {
Expand Down Expand Up @@ -511,6 +521,34 @@ export class AuthenticatedConnection extends Connection {
await actor.remove(this.userNumber, publicKey);
};

getIdentityInfo = async (): Promise<

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A client should only use the getAnchorMetadata, setPartialMetadata and commitMetadata functions. getIdentityInfo and setIdentityMetadata should be private then, no?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then the repository won't be able to use it...

I could pass the getter and setter as functions only.

{ Ok: IdentityInfo } | { Err: IdentityInfoError }
> => {
const actor = await this.getActor();
return await actor.identity_info(this.userNumber);
};

setIdentityMetadata = async (
metadata: MetadataMapV2
): Promise<{ Ok: null } | { Err: IdentityMetadataReplaceError }> => {
const actor = await this.getActor();
return await actor.identity_metadata_replace(this.userNumber, metadata);
};

getAnchorMetadata = (): Promise<AnchorMetadata> => {
return this.metadataRepository.getMetadata();
};

setPartialMetadata = async (

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
setPartialMetadata = async (
updateIdentityMetadata = async (

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

partialMetadata: Partial<AnchorMetadata>
): Promise<void> => {
await this.metadataRepository.setPartialMetadata(partialMetadata);
};

commitMetadata = async (): Promise<boolean> => {
return await this.metadataRepository.commitMetadata();
};

prepareDelegation = async (
origin_: FrontendHostname,
sessionKey: SessionKey,
Expand Down
Loading