diff --git a/backend/routes.js b/backend/routes.js index a4dd29225..950de537b 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -44,6 +44,20 @@ route.POST('/event', { const deserializedHEAD = GIMessage.deserializeHEAD(request.payload) try { await sbp('backend/server/handleEntry', deserializedHEAD, request.payload) + const name = request.headers['shelter-namespace-registration'] + // If this is the first message in a contract and the + // `shelter-namespace-registration` header is present, proceed with also + // registering a name for the new contract + if (deserializedHEAD.contractID === deserializedHEAD.hash && name && !name.startsWith('_private')) { + // Name registation is enabled only for identity contracts + const cheloniaState = sbp('chelonia/private/state') + if (cheloniaState.contracts[deserializedHEAD.contractID]?.type === 'gi.contracts/identity') { + const r = await sbp('backend/db/registerName', name, deserializedHEAD.contractID) + if (Boom.isBoom(r)) { + return r + } + } + } } catch (err) { console.error(err, chalk.bold.yellow(err.name)) if (err.name === 'ChelErrorDBBadPreviousHEAD' || err.name === 'ChelErrorAlreadyProcessed') { @@ -129,6 +143,10 @@ route.GET('/eventsBetween/{startHash}/{endHash}', {}, async function (request, h } }) +/* +// The following endpoint is disabled because name registrations are handled +// through the `shelter-namespace-registration` header when registering a +// new contract route.POST('/name', { validate: { payload: Joi.object({ @@ -146,6 +164,7 @@ route.POST('/name', { return err } }) +*/ route.GET('/name/{name}', {}, async function (request, h) { const { name } = request.params diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 076a46e0f..068b54c22 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -209,7 +209,8 @@ export default (sbp('sbp/selectors/register', { ], data: { attributes: { username, email, picture: finalPicture } - } + }, + namespaceRegistration: username }) userID = user.contractID() @@ -238,7 +239,6 @@ export default (sbp('sbp/selectors/register', { }, publishOptions }) - await sbp('namespace/register', username, userID) return userID } catch (e) { await sbp('gi.actions/identity/logout') // TODO: should this be here? diff --git a/frontend/controller/actions/types.js b/frontend/controller/actions/types.js index a7162fe87..d4dffaba5 100644 --- a/frontend/controller/actions/types.js +++ b/frontend/controller/actions/types.js @@ -5,6 +5,7 @@ export type GIRegParams = { contractID?: string; // always set on contract actions, but not when creating a contract data: Object; options?: Object; // these are options for the action wrapper + namespaceRegistration: ?string; hooks?: Object; publishOptions?: Object } diff --git a/frontend/controller/namespace.js b/frontend/controller/namespace.js index a2b2ea672..5fc3a4cda 100644 --- a/frontend/controller/namespace.js +++ b/frontend/controller/namespace.js @@ -2,10 +2,12 @@ import sbp from '@sbp/sbp' import Vue from 'vue' -import { handleFetchResult } from './utils/misc.js' // NOTE: prefix groups with `group/` and users with `user/` ? sbp('sbp/selectors/register', { + /* + // Registration is done when creating a contract, using the + // `shelter-namespace-registration` header 'namespace/register': (name: string, value: string) => { return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name`, { method: 'POST', @@ -18,16 +20,27 @@ sbp('sbp/selectors/register', { return result }) }, - 'namespace/lookup': (name: string) => { - // TODO: should `name` be encodeURI'd? + */ + 'namespace/lookupCached': (name: string) => { const cache = sbp('state/vuex/state').namespaceLookups if (name in cache) { // Wrapping in a Promise to return a consistent type across all execution // paths (next return is a Promise) // This way we can call .then() on the result - return Promise.resolve(cache[name]) + return cache[name] } - return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name/${name}`).then((r: Object) => { + }, + 'namespace/lookup': (name: string, { skipCache }: { skipCache: boolean } = { skipCache: false }) => { + if (!skipCache) { + const cached = sbp('namespace/lookupCached', name) + if (cached) { + // Wrapping in a Promise to return a consistent type across all execution + // paths (next return is a Promise) + // This way we can call .then() on the result + return Promise.resolve(cached) + } + } + return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name/${encodeURIComponent(name)}`).then((r: Object) => { if (!r.ok) { console.warn(`namespace/lookup: ${r.status} for ${name}`) if (r.status !== 404) { @@ -37,6 +50,7 @@ sbp('sbp/selectors/register', { } return r['text']() }).then(value => { + const cache = sbp('state/vuex/state').namespaceLookups if (value !== null) { Vue.set(cache, name, value) } diff --git a/frontend/main.js b/frontend/main.js index 53c61a796..dca4656af 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -111,7 +111,7 @@ async function startApp () { defaults: { modules: { '@common/common.js': Common }, allowedSelectors: [ - 'namespace/lookup', + 'namespace/lookup', 'namespace/lookupCached', 'state/vuex/state', 'state/vuex/settings', 'state/vuex/commit', 'state/vuex/getters', 'chelonia/contract/sync', 'chelonia/contract/isSyncing', 'chelonia/contract/remove', 'chelonia/contract/cancelRemove', 'controller/router', 'chelonia/contract/suitableSigningKey', 'chelonia/contract/currentKeyIdByName', diff --git a/frontend/model/contracts/identity.js b/frontend/model/contracts/identity.js index fcd169f4a..474450d44 100644 --- a/frontend/model/contracts/identity.js +++ b/frontend/model/contracts/identity.js @@ -14,6 +14,63 @@ import { import { IDENTITY_USERNAME_MAX_CHARS } from './shared/constants.js' +const attributesType = objectMaybeOf({ + username: string, + email: string, + picture: unionOf(string, objectOf({ + manifestCid: string, + downloadParams: optional(object) + })) +}) + +const validateUsername = (username: string) => { + if (!username) { + throw new TypeError('A username is required') + } + if (username.length > IDENTITY_USERNAME_MAX_CHARS) { + throw new TypeError(`A username cannot exceed ${IDENTITY_USERNAME_MAX_CHARS} characters.`) + } + if (!allowedUsernameCharacters(username)) { + throw new TypeError('A username cannot contain disallowed characters.') + } + if (!noConsecutiveHyphensOrUnderscores(username)) { + throw new TypeError('A username cannot contain two consecutive hyphens or underscores.') + } + if (!noLeadingOrTrailingHyphen(username)) { + throw new TypeError('A username cannot start or end with a hyphen.') + } + if (!noLeadingOrTrailingUnderscore(username)) { + throw new TypeError('A username cannot start or end with an underscore.') + } + if (!noUppercase(username)) { + throw new TypeError('A username cannot contain uppercase letters.') + } +} + +const checkUsernameConsistency = async (contractID: string, username: string) => { + // Lookup and save the username so that we can verify that it matches + const lookupResult = await sbp('namespace/lookup', username, { skipCache: true }) + if (lookupResult === contractID) return + + console.error(`Mismatched username. The lookup result was ${lookupResult} instead of ${contractID}`) + + // If there was a mismatch, wait until the contract is finished processing + // (because the username could have been updated), and if the situation + // persists, warn the user + sbp('chelonia/queueInvocation', contractID, () => { + const rootState = sbp('state/vuex/state') + if (!has(rootState, contractID)) return + + const username = rootState[contractID].attributes.username + if (sbp('namespace/lookupCached', username) !== contractID) { + sbp('gi.notifications/emit', 'WARNING', { + contractID, + message: L('Unable to confirm that the username {username} belongs to this identity contract', { username }) + }) + } + }) +} + sbp('chelonia/defineContract', { name: 'gi.contracts/identity', getters: { @@ -31,34 +88,13 @@ sbp('chelonia/defineContract', { 'gi.contracts/identity': { validate: (data, { state }) => { objectMaybeOf({ - attributes: objectMaybeOf({ - username: string, - email: string, - picture: unionOf(string, objectOf({ - manifestCid: string, - downloadParams: optional(object) - })) - }) + attributes: attributesType })(data) const { username } = data.attributes - if (username.length > IDENTITY_USERNAME_MAX_CHARS) { - throw new TypeError(`A username cannot exceed ${IDENTITY_USERNAME_MAX_CHARS} characters.`) - } - if (!allowedUsernameCharacters(username)) { - throw new TypeError('A username cannot contain disallowed characters.') - } - if (!noConsecutiveHyphensOrUnderscores(username)) { - throw new TypeError('A username cannot contain two consecutive hyphens or underscores.') - } - if (!noLeadingOrTrailingHyphen(username)) { - throw new TypeError('A username cannot start or end with a hyphen.') - } - if (!noLeadingOrTrailingUnderscore(username)) { - throw new TypeError('A username cannot start or end with an underscore.') - } - if (!noUppercase(username)) { - throw new TypeError('A username cannot contain uppercase letters.') + if (!username) { + throw new TypeError('A username is required') } + validateUsername(username) }, process ({ data }, { state }) { const initialState = merge({ @@ -70,18 +106,36 @@ sbp('chelonia/defineContract', { for (const key in initialState) { Vue.set(state, key, initialState[key]) } + }, + async sideEffect ({ contractID, data }) { + await checkUsernameConsistency(contractID, data.attributes.username) } }, 'gi.contracts/identity/setAttributes': { - validate: object, + validate: (data) => { + attributesType(data) + if (has(data, 'username')) { + validateUsername(data.username) + } + }, process ({ data }, { state }) { for (const key in data) { Vue.set(state.attributes, key, data[key]) } + }, + async sideEffect ({ contractID, data }) { + if (has(data, 'username')) { + await checkUsernameConsistency(contractID, data.username) + } } }, 'gi.contracts/identity/deleteAttributes': { - validate: arrayOf(string), + validate: (data) => { + arrayOf(string)(data) + if (data.includes('username')) { + throw new Error('Username can\'t be deleted') + } + }, process ({ data }, { state }) { for (const attribute of data) { Vue.delete(state.attributes, attribute) diff --git a/frontend/model/state.js b/frontend/model/state.js index 8cc22c0aa..120ecdb04 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -38,6 +38,12 @@ if (window.matchMedia) { }) } +const checkedUsername = (state: Object, username: string, userID: string) => { + if (username && state.namespaceLookups[username] === userID) { + return username + } +} + const reactiveDate = Vue.observable({ date: new Date() }) setInterval(function () { // We want the getters to recalculate all of the payments within 1 minute of us entering a new period. @@ -227,12 +233,12 @@ const getters = { } }, userDisplayNameFromID (state, getters) { - return (user) => { - if (user === getters.ourIdentityContractId) { + return (userID) => { + if (userID === getters.ourIdentityContractId) { return getters.ourUserDisplayName } - const profile = getters.ourContactProfilesById[user] || {} - return profile.displayName || profile.username || user + const profile = getters.ourContactProfilesById[userID] || {} + return profile.displayName || profile.username || userID } }, // this getter gets recomputed automatically according to the setInterval on reactiveDate @@ -468,7 +474,12 @@ const getters = { .forEach(contractID => { const attributes = state[contractID].attributes if (attributes) { // NOTE: this is for fixing the error while syncing the identity contracts - profiles[attributes.username] = { ...attributes, contractID } + const username = checkedUsername(state, attributes.username, contractID) + profiles[attributes.username] = { + ...attributes, + username, + contractID + } } }) return profiles @@ -480,7 +491,12 @@ const getters = { .forEach(contractID => { const attributes = state[contractID].attributes if (attributes) { // NOTE: this is for fixing the error while syncing the identity contracts - profiles[contractID] = { ...attributes, contractID } + const username = checkedUsername(state, attributes.username, contractID) + profiles[contractID] = { + ...attributes, + username, + contractID + } } }) return profiles diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index 3e0c78ca5..8a6041e78 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -31,6 +31,7 @@ export type ChelRegParams = { actionSigningKeyId: string; actionEncryptionKeyId: ?string; keys: (GIKey | EncryptedData)[]; + namespaceRegistration: ?string; hooks?: { prepublishContract?: (GIMessage) => void; postpublishContract?: (GIMessage) => void; @@ -40,7 +41,7 @@ export type ChelRegParams = { postpublish?: (GIMessage) => void; onprocessed?: (GIMessage) => void; }; - publishOptions?: { maxAttempts: number }; + publishOptions?: { headers: ?Object, maxAttempts: number }; } export type ChelActionParams = { @@ -923,7 +924,15 @@ export default (sbp('sbp/selectors/register', { manifest: manifestHash }) const contractID = contractMsg.hash() - await sbp('chelonia/private/out/publishEvent', contractMsg, publishOptions, hooks && { + await sbp('chelonia/private/out/publishEvent', contractMsg, (params.namespaceRegistration + ? { + ...publishOptions, + headers: { + ...publishOptions?.headers, + 'shelter-namespace-registration': params.namespaceRegistration + } + } + : publishOptions), hooks && { prepublish: hooks.prepublishContract, postpublish: hooks.postpublishContract }) diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index b0d745711..7a897cbb4 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -409,7 +409,7 @@ export default (sbp('sbp/selectors/register', { }, // used by, e.g. 'chelonia/contract/wait' 'chelonia/private/noop': function () {}, - 'chelonia/private/out/publishEvent': function (entry: GIMessage, { maxAttempts = 5 } = {}, hooks) { + 'chelonia/private/out/publishEvent': function (entry: GIMessage, { maxAttempts = 5, headers } = {}, hooks) { const contractID = entry.contractID() const originalEntry = entry @@ -488,6 +488,7 @@ export default (sbp('sbp/selectors/register', { method: 'POST', body: entry.serialize(), headers: { + ...headers, 'Content-Type': 'text/plain', 'Authorization': 'gi TODO - signature - if needed here - goes here' }, diff --git a/test/backend.test.js b/test/backend.test.js index 3136d530f..3d8f8cd48 100644 --- a/test/backend.test.js +++ b/test/backend.test.js @@ -154,7 +154,8 @@ describe('Full walkthrough', function () { // TODO when merging: decryptedValue no longer takes an argument (was decryptedValue(JSON.parse)) prepublish: (message) => { message.decryptedValue() }, postpublish: (message) => { testFn && testFn(message) } - } + }, + namespaceRegistration: username }) return msg } @@ -253,7 +254,12 @@ describe('Full walkthrough', function () { users.bob.decryptedValue().data.attributes.email.should.equal('bob@okturtles.com') }) - it('Should register Alice and Bob in the namespace', async function () { + /* + // The following test is redundant because now namespace registration happens + // when registering a contract instead of after it's registered using + // 'namespace/register'. If we have no further use for 'namespace/register', + // consider removing this entirely + it.skip('Should register Alice and Bob in the namespace', async function () { const { alice, bob } = users let res = await sbp('namespace/register', alice.decryptedValue().data.attributes.username, alice.contractID()) // NOTE: don't rely on the return values for 'namespace/register' @@ -264,6 +270,7 @@ describe('Full walkthrough', function () { alice.socket = 'hello' should(alice.socket).equal('hello') }) + */ it('Should verify namespace lookups work', async function () { const { alice } = users