diff --git a/src/ledger/user.js b/src/ledger/user.js new file mode 100644 index 0000000..fd24676 --- /dev/null +++ b/src/ledger/user.js @@ -0,0 +1,105 @@ +// @flow + +/** + * This module has a core data type identifying SourceCred users. + * + * The scope for this data type is to model: + * - a unique identifier for each user + * - a unique (renameable) username they choose + * - the address of every node they correspond to in the graph + * + * Unlike most other state in SourceCred, the User state is + * nondeterministically generated by SourceCred itself, and then persisted + * long-term within the instance. + * + * This is in contrast to Graph data, which usually comes from an external + * source, and is not persisted long-term, but instead is re-generated when + * needed. + * + * In particular, this kernel of user data is stored within the core ledger, + * since it's necessary to track consistently when tracking Grain distribution. + * This type should not grow to include all the data that the UI will + * eventually want to show; that should be kept in a different data store which + * isn't being used as a transaction ledger. + * + */ +import { + type Uuid, + parser as uuidParser, + random as randomUuid, +} from "../util/uuid"; +import * as C from "../util/combo"; +import { + type NodeAddressT, + NodeAddress, + type Node as GraphNode, +} from "../core/graph"; + +/** + * We validate usernames using GitHub-esque rules. + */ +export opaque type Username: string = string; +const USERNAME_PATTERN = /^@?([A-Za-z0-9-_]+)$/; + +export type UserId = Uuid; +export type User = {| + // UUID, assigned when the user is created. + +id: UserId, + +name: Username, + // Every other node in the graph that this user corresponds to. + // Does not include the user's "own" address, i.e. the result + // of calling (userAddress(user.id)). + +aliases: $ReadOnlyArray, +|}; + +// It's not in the typical [owner, name] format because it isn't provided by a plugin. +// Instead, it's a raw type owned by SourceCred project. +export const USER_PREFIX = NodeAddress.fromParts(["sourcecred", "USER"]); + +/** + * Create a new user, assigning a random id. + */ +export function createUser(name: string): User { + return { + id: randomUuid(), + name, + aliases: [], + }; +} + +/** + * Parse a Username from a string. + * + * Throws an error if the username is invalid. + */ +export function usernameFromString(username: string): Username { + const re = new RegExp(USERNAME_PATTERN); + const match = re.exec(username); + if (match == null) { + throw new Error(`invalid username: ${username}`); + } + return match[1]; +} + +export function userAddress(id: UserId): NodeAddressT { + return NodeAddress.append(USER_PREFIX, id); +} + +export function graphNode({id, name}: User): GraphNode { + return { + address: userAddress(id), + description: name, + timestampMs: null, + }; +} + +export const usernameParser: C.Parser = C.fmap( + C.string, + usernameFromString +); + +export const userParser: C.Parser = C.object({ + id: uuidParser, + name: usernameParser, + aliases: C.array(C.fmap(C.string, NodeAddress.fromRaw)), +}); diff --git a/src/ledger/user.test.js b/src/ledger/user.test.js new file mode 100644 index 0000000..d5eeae2 --- /dev/null +++ b/src/ledger/user.test.js @@ -0,0 +1,53 @@ +// @flow + +import deepFreeze from "deep-freeze"; +import {fromString as uuidFromString} from "../util/uuid"; +import {NodeAddress} from "../core/graph"; +import { + createUser, + userAddress, + usernameFromString, + USER_PREFIX, + graphNode, + type User, +} from "./user"; + +describe("ledger/user", () => { + const uuid = uuidFromString("YVZhbGlkVXVpZEF0TGFzdA"); + const name = usernameFromString("foo"); + const example: User = deepFreeze({ + id: uuid, + name, + aliases: [NodeAddress.empty], + }); + it("createUser works", () => { + const user = createUser(name); + expect(user.aliases).toEqual([]); + expect(user.name).toEqual(name); + // Verify it is a valid UUID + uuidFromString(user.id); + }); + it("userAddress works", () => { + expect(userAddress(uuid)).toEqual(NodeAddress.append(USER_PREFIX, uuid)); + }); + it("graphNode works", () => { + const node = graphNode(example); + expect(node.description).toEqual(example.name); + expect(node.address).toEqual(userAddress(uuid)); + expect(node.timestampMs).toEqual(null); + }); + describe("usernameFromString", () => { + it("fails on invalid usernames", () => { + const bad = ["With Space", "With.Period", "A/Slash", ""]; + for (const b of bad) { + expect(() => usernameFromString(b)).toThrowError("invalid username"); + } + }); + it("succeeds on valid usernames", () => { + const names = ["h", "hi_there", "ZaX99324cab"]; + for (const n of names) { + expect(usernameFromString(n)).toEqual(n); + } + }); + }); +});