-
Notifications
You must be signed in to change notification settings - Fork 135
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
Changes from 3 commits
46c7f0b
302377b
1718335
0a230a0
270c219
84c55ec
982b4a3
a646146
a1930e1
9b28f4b
81ef6c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
}; | ||
|
||
const RECOVERY_PAGE_SHOW_TIMESTAMP_MILLIS = "recoveryPageShownTimestampMillis"; | ||
const METADATA_FETCH_TIMEOUT = 1_000; | ||
const METADATA_FETCH_RETRIES = 10; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd suggest to extract a helper for this condition. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -14,7 +14,11 @@ import { | |||||
GetDelegationResponse, | ||||||
IdAliasCredentials, | ||||||
IdentityAnchorInfo, | ||||||
IdentityInfo, | ||||||
IdentityInfoError, | ||||||
IdentityMetadataReplaceError, | ||||||
KeyType, | ||||||
MetadataMapV2, | ||||||
PreparedIdAlias, | ||||||
PublicKey, | ||||||
Purpose, | ||||||
|
@@ -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, | ||||||
|
@@ -411,6 +419,7 @@ export class Connection { | |||||
} | ||||||
|
||||||
export class AuthenticatedConnection extends Connection { | ||||||
private metadataRepository: AnchorMetadataRepository; | ||||||
public constructor( | ||||||
public canisterId: string, | ||||||
public identity: SignIdentity, | ||||||
|
@@ -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>> { | ||||||
|
@@ -511,6 +521,34 @@ export class AuthenticatedConnection extends Connection { | |||||
await actor.remove(this.userNumber, publicKey); | ||||||
}; | ||||||
|
||||||
getIdentityInfo = async (): Promise< | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A client should only use the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||||||
|
There was a problem hiding this comment.
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
?There was a problem hiding this comment.
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 anumber
.From chatGPT:
If we were using nanoseconds, then yes. But with milliseconds it should be enough.