@@ -288,6 +290,7 @@ function LogoutObstacle ({ onClose }) {
}
await wallets.resetClient().catch(console.error)
+ await resetVaultKey(me?.id)
await signOut({ callbackUrl: '/' })
}}
diff --git a/components/use-vault.js b/components/use-vault.js
new file mode 100644
index 00000000..e65df387
--- /dev/null
+++ b/components/use-vault.js
@@ -0,0 +1,487 @@
+import { useCallback, useState, useEffect } from 'react'
+import { useMe } from '@/components/me'
+import { useMutation, useQuery } from '@apollo/client'
+import { GET_ENTRY, SET_ENTRY, UNSET_ENTRY, CLEAR_VAULT, SET_VAULT_KEY_HASH } from '@/fragments/vault'
+import { E_VAULT_KEY_EXISTS } from '@/lib/error'
+import { SSR } from '@/lib/constants'
+import { useToast } from '@/components/toast'
+
+const USE_INDEXEDDB = true
+
+export function useVaultConfigurator () {
+ const { me } = useMe()
+ const [setVaultKeyHash] = useMutation(SET_VAULT_KEY_HASH)
+ const toaster = useToast()
+
+ // vault key stored locally
+ const [vaultKey, innerSetVaultKey] = useState(null)
+
+ useEffect(() => {
+ if (!me) return
+ (async () => {
+ let localVaultKey = await getLocalKey(me.id)
+
+ if (!me.privates.vaultKeyHash || localVaultKey?.hash !== me.privates.vaultKeyHash) {
+ // We can tell that another device has reset the vault if the values
+ // on the server are encrypted with a different key or no key exists anymore.
+ // In that case, our local key is no longer valid and our device needs to be connected
+ // to the vault again by entering the correct passphrase.
+ console.log('vault key hash mismatch, clearing local key', localVaultKey, me.privates.vaultKeyHash)
+ localVaultKey = null
+ await unsetLocalKey(me.id)
+ }
+
+ innerSetVaultKey(localVaultKey)
+ })()
+ }, [me?.privates?.vaultKeyHash])
+
+ // clear vault: remove everything and reset the key
+ const [clearVault] = useMutation(CLEAR_VAULT, {
+ onCompleted: async () => {
+ await unsetLocalKey(me.id)
+ innerSetVaultKey(null)
+ }
+ })
+
+ // initialize the vault and set a vault key
+ const setVaultKey = useCallback(async (passphrase) => {
+ const vaultKey = await deriveKey(me.id, passphrase)
+ await setVaultKeyHash({
+ variables: { hash: vaultKey.hash },
+ onError: (error) => {
+ const errorCode = error.graphQLErrors[0]?.extensions?.code
+ if (errorCode === E_VAULT_KEY_EXISTS) {
+ throw new Error('wrong passphrase')
+ }
+ toaster.danger(error.graphQLErrors[0].message)
+ }
+ })
+ innerSetVaultKey(vaultKey)
+ await setLocalKey(me.id, vaultKey)
+ }, [setVaultKeyHash])
+
+ // disconnect the user from the vault (will not clear or reset the passphrase, use clearVault for that)
+ const disconnectVault = useCallback(async () => {
+ await unsetLocalKey(me.id)
+ innerSetVaultKey(null)
+ }, [innerSetVaultKey])
+
+ return [vaultKey, setVaultKey, clearVault, disconnectVault]
+}
+
+export function useVaultMigration () {
+ const { me } = useMe()
+ const [setVaultEntry] = useMutation(SET_ENTRY)
+
+ // migrate local storage to vault
+ const migrate = useCallback(async () => {
+ const vaultKey = await getLocalKey(me.id)
+ if (!vaultKey) throw new Error('vault key not found')
+
+ let migratedCount = 0
+
+ for (const migratableKey of retrieveMigratableKeys(me.id)) {
+ try {
+ const value = JSON.parse(window.localStorage.getItem(migratableKey.localStorageKey))
+ if (!value) throw new Error('no value found in local storage')
+
+ const encrypted = await encryptJSON(vaultKey, value)
+
+ const { data } = await setVaultEntry({ variables: { key: migratableKey.vaultStorageKey, value: encrypted, skipIfSet: true } })
+ if (data?.setVaultEntry) {
+ window.localStorage.removeItem(migratableKey.localStorageKey)
+ migratedCount++
+ console.log('migrated to vault:', migratableKey)
+ } else {
+ throw new Error('could not set vault entry')
+ }
+ } catch (e) {
+ console.error('failed migrate to vault:', migratableKey, e)
+ }
+ }
+
+ return migratedCount
+ }, [me?.id])
+
+ return migrate
+}
+
+// used to get and set values in the vault
+export default function useVault (vaultStorageKey, defaultValue, options = { localOnly: false }) {
+ const { me } = useMe()
+ const localOnly = options.localOnly || !me
+
+ // This is the key that we will use in local storage whereas vaultStorageKey is the key that we
+ // will use on the server ("the vault").
+ const localStorageKey = getLocalStorageKey(vaultStorageKey, me?.id, localOnly)
+
+ const [setVaultValue] = useMutation(SET_ENTRY)
+ const [value, innerSetValue] = useState(undefined)
+ const [clearVaultValue] = useMutation(UNSET_ENTRY)
+ const { data: vaultData, refetch: refetchVaultValue } = useQuery(GET_ENTRY, {
+ variables: { key: vaultStorageKey },
+ // fetchPolicy only applies to first execution on mount so we also need to
+ // set nextFetchPolicy to make sure we don't serve stale values from cache
+ nextFetchPolicy: 'no-cache',
+ fetchPolicy: 'no-cache'
+ })
+
+ useEffect(() => {
+ (async () => {
+ if (localOnly) {
+ innerSetValue((await getLocalStorage(localStorageKey)) || defaultValue)
+ return
+ }
+
+ const localVaultKey = await getLocalKey(me?.id)
+
+ if (!me.privates.vaultKeyHash || localVaultKey?.hash !== me.privates.vaultKeyHash) {
+ // no or different vault setup on server
+ // use unencrypted local storage
+ await unsetLocalKey(me.id)
+ innerSetValue((await getLocalStorage(localStorageKey)) || defaultValue)
+ return
+ }
+
+ // if vault key hash is set on the server, vault entry exists and vault key is set on the device
+ // decrypt and use the value from the server
+ const encrypted = vaultData?.getVaultEntry?.value
+ if (encrypted) {
+ try {
+ const decrypted = await decryptJSON(localVaultKey, encrypted)
+ // console.log('decrypted value from vault:', storageKey, encrypted, decrypted)
+ innerSetValue(decrypted)
+ // remove local storage value if it exists
+ await unsetLocalStorage(localStorageKey)
+ return
+ } catch (e) {
+ console.error('cannot read vault data:', vaultStorageKey, e)
+ }
+ }
+
+ // fallback to local storage
+ innerSetValue((await getLocalStorage(localStorageKey)) || defaultValue)
+ })()
+ }, [vaultData, me?.privates?.vaultKeyHash, localOnly])
+
+ const setValue = useCallback(async (newValue) => {
+ const vaultKey = await getLocalKey(me?.id)
+
+ const useVault = vaultKey && vaultKey.hash === me.privates.vaultKeyHash
+
+ if (useVault && !localOnly) {
+ const encryptedValue = await encryptJSON(vaultKey, newValue)
+ await setVaultValue({ variables: { key: vaultStorageKey, value: encryptedValue } })
+ console.log('stored encrypted value in vault:', vaultStorageKey, encryptedValue)
+ // clear local storage (we get rid of stored unencrypted data as soon as it can be stored on the vault)
+ await unsetLocalStorage(localStorageKey)
+ } else {
+ console.log('stored value in local storage:', localStorageKey, newValue)
+ // otherwise use local storage
+ await setLocalStorage(localStorageKey, newValue)
+ }
+ // refresh in-memory value
+ innerSetValue(newValue)
+ }, [me?.privates?.vaultKeyHash, localStorageKey, vaultStorageKey, localOnly])
+
+ const clearValue = useCallback(async ({ onlyFromLocalStorage }) => {
+ // unset a value
+ // clear server
+ if (!localOnly && !onlyFromLocalStorage) {
+ await clearVaultValue({ variables: { key: vaultStorageKey } })
+ await refetchVaultValue()
+ }
+ // clear local storage
+ await unsetLocalStorage(localStorageKey)
+ // clear in-memory value
+ innerSetValue(undefined)
+ }, [vaultStorageKey, localStorageKey, localOnly])
+
+ return [value, setValue, clearValue, refetchVaultValue]
+}
+
+function retrieveMigratableKeys (userId) {
+ // get all the local storage keys that can be migrated
+ const out = []
+
+ for (const key of Object.keys(window.localStorage)) {
+ if (key.includes(':local-only:')) continue
+ if (!key.endsWith(`:${userId}`)) continue
+
+ if (key.startsWith('vault:')) {
+ out.push({
+ vaultStorageKey: key.substring('vault:'.length, key.length - `:${userId}`.length),
+ localStorageKey: key
+ })
+ }
+
+ // required for backwards compatibility with keys that were stored before we had the vault
+ if (key.startsWith('wallet:')) {
+ out.push({
+ vaultStorageKey: key.substring(0, key.length - `:${userId}`.length),
+ localStorageKey: key
+ })
+ }
+ }
+ return out
+}
+
+async function getLocalStorageBackend (useIndexedDb) {
+ if (SSR) return null
+ if (USE_INDEXEDDB && useIndexedDb && window.indexedDB && !window.snVaultIDB) {
+ try {
+ const storage = await new Promise((resolve, reject) => {
+ const db = window.indexedDB.open('sn-vault', 1)
+ db.onupgradeneeded = (event) => {
+ const db = event.target.result
+ db.createObjectStore('vault', { keyPath: 'key' })
+ }
+ db.onsuccess = () => {
+ if (!db?.result?.transaction) reject(new Error('unsupported implementation'))
+ else resolve(db.result)
+ }
+ db.onerror = reject
+ })
+ window.snVaultIDB = storage
+ } catch (e) {
+ console.error('could not use indexedDB:', e)
+ }
+ }
+
+ const isIDB = useIndexedDb && !!window.snVaultIDB
+
+ return {
+ isIDB,
+ set: async (key, value) => {
+ if (isIDB) {
+ const tx = window.snVaultIDB.transaction(['vault'], 'readwrite')
+ const objectStore = tx.objectStore('vault')
+ objectStore.add({ key, value })
+ await new Promise((resolve, reject) => {
+ tx.oncomplete = resolve
+ tx.onerror = reject
+ })
+ } else {
+ window.localStorage.setItem(key, JSON.stringify(value))
+ }
+ },
+ get: async (key) => {
+ if (isIDB) {
+ const tx = window.snVaultIDB.transaction(['vault'], 'readonly')
+ const objectStore = tx.objectStore('vault')
+ const request = objectStore.get(key)
+ return await new Promise((resolve, reject) => {
+ request.onsuccess = () => resolve(request.result?.value)
+ request.onerror = reject
+ })
+ } else {
+ const v = window.localStorage.getItem(key)
+ return v ? JSON.parse(v) : null
+ }
+ },
+ clear: async (key) => {
+ if (isIDB) {
+ const tx = window.snVaultIDB.transaction(['vault'], 'readwrite')
+ const objectStore = tx.objectStore('vault')
+ objectStore.delete(key)
+ await new Promise((resolve, reject) => {
+ tx.oncomplete = resolve
+ tx.onerror = reject
+ })
+ } else {
+ window.localStorage.removeItem(key)
+ }
+ }
+ }
+}
+
+function getLocalStorageKey (key, userId, localOnly) {
+ if (!userId) userId = 'anon'
+ // We prefix localStorageKey with 'vault:' so we know which
+ // keys we need to migrate to the vault when device sync is enabled.
+ let localStorageKey = `vault:${key}`
+
+ // wallets like WebLN don't make sense to share across devices since they rely on a browser extension.
+ // We check for this ':local-only:' tag during migration to skip any keys that contain it.
+ if (localOnly) {
+ localStorageKey = `vault:local-only:${key}`
+ }
+
+ // always scope to user to avoid messing with wallets of other users on same device that might exist
+ return `${localStorageKey}:${userId}`
+}
+
+async function setLocalKey (userId, localKey) {
+ if (SSR) return
+ if (!userId) userId = 'anon'
+ const storage = await getLocalStorageBackend(true)
+ const k = `vault-key:local-only:${userId}`
+ const { key, hash } = localKey
+
+ const rawKey = await window.crypto.subtle.exportKey('raw', key)
+ if (storage.isIDB) {
+ let nonExtractableKey
+ // if IDB, we ensure the key is non extractable
+ if (localKey.extractable) {
+ nonExtractableKey = await window.crypto.subtle.importKey(
+ 'raw',
+ rawKey,
+ { name: 'AES-GCM' },
+ false,
+ ['encrypt', 'decrypt']
+ )
+ } else {
+ nonExtractableKey = localKey.key
+ }
+ // and we store it
+ return await storage.set(k, { key: nonExtractableKey, hash, extractable: false })
+ } else {
+ // if non IDB we need to serialize the key to store it
+ const keyHex = toHex(rawKey)
+ return await storage.set(k, { key: keyHex, hash, extractable: true })
+ }
+}
+
+async function getLocalKey (userId) {
+ if (SSR) return null
+ if (!userId) userId = 'anon'
+ const storage = await getLocalStorageBackend(true)
+ const key = await storage.get(`vault-key:local-only:${userId}`)
+ if (!key) return null
+ if (!storage.isIDB) {
+ // if non IDB we need to deserialize the key
+ const rawKey = fromHex(key.key)
+ const keyMaterial = await window.crypto.subtle.importKey(
+ 'raw',
+ rawKey,
+ { name: 'AES-GCM' },
+ false,
+ ['encrypt', 'decrypt']
+ )
+ key.key = keyMaterial
+ key.extractable = true
+ }
+ return key
+}
+
+export async function unsetLocalKey (userId) {
+ if (SSR) return
+ if (!userId) userId = 'anon'
+ const storage = await getLocalStorageBackend(true)
+ return await storage.clear(`vault-key:local-only:${userId}`)
+}
+
+async function setLocalStorage (key, value) {
+ if (SSR) return
+ const storage = await getLocalStorageBackend(false)
+ await storage.set(key, value)
+}
+
+async function getLocalStorage (key) {
+ if (SSR) return null
+ const storage = await getLocalStorageBackend(false)
+ let v = await storage.get(key)
+
+ // ensure backwards compatible with wallet keys that we used before we had the vault
+ if (!v) {
+ const oldKey = key.replace(/vault:(local-only:)?/, '')
+ v = await storage.get(oldKey)
+ }
+
+ return v
+}
+
+async function unsetLocalStorage (key) {
+ if (SSR) return
+ const storage = await getLocalStorageBackend(false)
+ await storage.clear(key)
+}
+
+function toHex (buffer) {
+ const byteArray = new Uint8Array(buffer)
+ const hexString = Array.from(byteArray, byte => byte.toString(16).padStart(2, '0')).join('')
+ return hexString
+}
+
+function fromHex (hex) {
+ const byteArray = new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)))
+ return byteArray.buffer
+}
+
+async function deriveKey (userId, passphrase) {
+ const enc = new TextEncoder()
+ const keyMaterial = await window.crypto.subtle.importKey(
+ 'raw',
+ enc.encode(passphrase),
+ { name: 'PBKDF2' },
+ false,
+ ['deriveKey']
+ )
+ const key = await window.crypto.subtle.deriveKey(
+ {
+ name: 'PBKDF2',
+ salt: enc.encode(`stacker${userId}`),
+ // 600,000 iterations is recommended by OWASP
+ // see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
+ iterations: 600_000,
+ hash: 'SHA-256'
+ },
+ keyMaterial,
+ { name: 'AES-GCM', length: 256 },
+ true,
+ ['encrypt', 'decrypt']
+ )
+ const rawKey = await window.crypto.subtle.exportKey('raw', key)
+ const rawHash = await window.crypto.subtle.digest('SHA-256', rawKey)
+ return {
+ key,
+ hash: toHex(rawHash),
+ extractable: true
+ }
+}
+
+async function encryptJSON (localKey, jsonData) {
+ const { key } = localKey
+
+ // random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure
+ // see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm
+ const iv = window.crypto.getRandomValues(new Uint8Array(12))
+
+ const encoded = new TextEncoder().encode(JSON.stringify(jsonData))
+
+ const encrypted = await window.crypto.subtle.encrypt(
+ {
+ name: 'AES-GCM',
+ iv
+ },
+ key,
+ encoded
+ )
+
+ return JSON.stringify({
+ iv: toHex(iv.buffer),
+ data: toHex(encrypted)
+ })
+}
+
+async function decryptJSON (localKey, encryptedData) {
+ const { key } = localKey
+
+ let { iv, data } = JSON.parse(encryptedData)
+
+ iv = fromHex(iv)
+ data = fromHex(data)
+
+ const decrypted = await window.crypto.subtle.decrypt(
+ {
+ name: 'AES-GCM',
+ iv
+ },
+ key,
+ data
+ )
+
+ const decoded = new TextDecoder().decode(decrypted)
+
+ return JSON.parse(decoded)
+}
diff --git a/fragments/users.js b/fragments/users.js
index dfdb2df6..0383dcd4 100644
--- a/fragments/users.js
+++ b/fragments/users.js
@@ -55,6 +55,7 @@ export const ME = gql`
autoWithdrawMaxFeePercent
autoWithdrawThreshold
disableFreebies
+ vaultKeyHash
}
optional {
isContributor
@@ -390,3 +391,9 @@ export const USER_STATS = gql`
}
}
}`
+
+export const SET_VAULT_KEY_HASH = gql`
+ mutation setVaultKeyHash($hash: String!) {
+ setVaultKeyHash(hash: $hash)
+ }
+`
diff --git a/fragments/vault.js b/fragments/vault.js
new file mode 100644
index 00000000..6c313a6a
--- /dev/null
+++ b/fragments/vault.js
@@ -0,0 +1,33 @@
+import { gql } from '@apollo/client'
+
+export const GET_ENTRY = gql`
+ query GetVaultEntry($key: String!) {
+ getVaultEntry(key: $key) {
+ value
+ }
+ }
+`
+
+export const SET_ENTRY = gql`
+ mutation SetVaultEntry($key: String!, $value: String!, $skipIfSet: Boolean) {
+ setVaultEntry(key: $key, value: $value, skipIfSet: $skipIfSet)
+ }
+`
+
+export const UNSET_ENTRY = gql`
+ mutation UnsetVaultEntry($key: String!) {
+ unsetVaultEntry(key: $key)
+ }
+`
+
+export const CLEAR_VAULT = gql`
+ mutation ClearVault {
+ clearVault
+ }
+`
+
+export const SET_VAULT_KEY_HASH = gql`
+ mutation SetVaultKeyHash($hash: String!) {
+ setVaultKeyHash(hash: $hash)
+ }
+`
diff --git a/lib/error.js b/lib/error.js
index fcbade2b..3ba45521 100644
--- a/lib/error.js
+++ b/lib/error.js
@@ -3,6 +3,7 @@ import { GraphQLError } from 'graphql'
export const E_FORBIDDEN = 'E_FORBIDDEN'
export const E_UNAUTHENTICATED = 'E_UNAUTHENTICATED'
export const E_BAD_INPUT = 'E_BAD_INPUT'
+export const E_VAULT_KEY_EXISTS = 'E_VAULT_KEY_EXISTS'
export class GqlAuthorizationError extends GraphQLError {
constructor (message) {
@@ -17,7 +18,7 @@ export class GqlAuthenticationError extends GraphQLError {
}
export class GqlInputError extends GraphQLError {
- constructor (message) {
- super(message, { extensions: { code: E_BAD_INPUT } })
+ constructor (message, code) {
+ super(message, { extensions: { code: code || E_BAD_INPUT } })
}
}
diff --git a/lib/validate.js b/lib/validate.js
index cb52cdb4..584226b7 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -840,3 +840,23 @@ export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE
}
export const toPositiveNumber = (x) => toNumber(x, 0)
+
+export const deviceSyncSchema = object().shape({
+ passphrase: string().required('required')
+ .test(async (value, context) => {
+ const words = value ? value.trim().split(/[\s]+/) : []
+ for (const w of words) {
+ try {
+ await string().oneOf(bip39Words).validate(w)
+ } catch {
+ return context.createError({ message: `'${w}' is not a valid pairing phrase word` })
+ }
+ }
+
+ if (words.length < 12) {
+ return context.createError({ message: 'needs at least 12 words' })
+ }
+
+ return true
+ })
+})
diff --git a/pages/settings/index.js b/pages/settings/index.js
index 10f043e2..f35504c0 100644
--- a/pages/settings/index.js
+++ b/pages/settings/index.js
@@ -31,6 +31,7 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import { useField } from 'formik'
import styles from './settings.module.css'
import { AuthBanner } from '@/components/banners'
+import DeviceSync from '@/components/device-sync'
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
@@ -606,6 +607,7 @@ export default function Settings ({ ssrData }) {
saturday newsletter
(re)subscribe
{settings?.authMethods &&
}
+
diff --git a/prisma/migrations/20240922065504_vault/migration.sql b/prisma/migrations/20240922065504_vault/migration.sql
new file mode 100644
index 00000000..163024f8
--- /dev/null
+++ b/prisma/migrations/20240922065504_vault/migration.sql
@@ -0,0 +1,39 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN "vaultKeyHash" TEXT NOT NULL DEFAULT '';
+
+-- CreateTable
+CREATE TABLE "Vault" (
+ "id" SERIAL NOT NULL,
+ "key" VARCHAR(64) NOT NULL,
+ "value" TEXT NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Vault_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Vault.userId_index" ON "Vault"("userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Vault_userId_key_key" ON "Vault"("userId", "key");
+
+-- AddForeignKey
+ALTER TABLE "Vault" ADD CONSTRAINT "Vault_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- avoid spam
+CREATE OR REPLACE FUNCTION enforce_vault_limit()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF (SELECT COUNT(*) FROM "Vault" WHERE "userId" = NEW."userId") >= 100 THEN
+ RAISE EXCEPTION 'vault limit of 100 entries per user reached';
+ END IF;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER enforce_vault_limit_trigger
+BEFORE INSERT ON "Vault"
+FOR EACH ROW
+EXECUTE FUNCTION enforce_vault_limit();
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1a9a3795..6d8e26b7 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -134,6 +134,8 @@ model User {
ItemUserAgg ItemUserAgg[]
oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer")
oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees")
+ vaultKeyHash String @default("")
+ vaultEntries Vault[] @relation("VaultEntries")
@@index([photoId])
@@index([createdAt], map: "users.created_at_index")
@@ -1099,6 +1101,19 @@ model Reminder {
@@index([userId, remindAt], map: "Reminder.userId_reminderAt_index")
}
+model Vault {
+ id Int @id @default(autoincrement())
+ key String @db.VarChar(64)
+ value String @db.Text
+ userId Int
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade, name: "VaultEntries")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
+
+ @@unique([userId, key])
+ @@index([userId], map: "Vault.userId_index")
+}
+
enum EarnType {
POST
COMMENT
diff --git a/svgs/clipboard-line.svg b/svgs/clipboard-line.svg
new file mode 100644
index 00000000..6362c172
--- /dev/null
+++ b/svgs/clipboard-line.svg
@@ -0,0 +1 @@
+