Skip to content

feat: [FRONT-2099] support arbitrary hex strings for granting pubsub permissions on Hub #2003

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

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:
- name: Start Streamr Docker Stack
uses: streamr-dev/streamr-docker-dev-action@v1.0.1
with:
services-to-start: "cassandra mysql nginx entry-point broker-node-storage-1 broker-node-no-storage-1 broker-node-no-storage-2 dev-chain-fast postgres-fastchain"
services-to-start: "cassandra mysql entry-point broker-node-storage-1 broker-node-no-storage-1 broker-node-no-storage-2 dev-chain-fast postgres-fastchain"
- name: Run Jest Tests
run: echo $CHUNKS | jq '.[${{ matrix.chunk }}] | .[] | @text' | xargs npx jest --verbose --useStderr --forceExit --coverage=false --logHeapUsage --runInBand
env:
Expand Down
51 changes: 29 additions & 22 deletions src/modals/NewStreamPermissionsModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React, { useState } from 'react'
import styled from 'styled-components'
import { isAddress } from 'web3-validator'
import { address0 } from '~/consts'
import { StreamPermission } from '@streamr/sdk'
import PermissionEditor from '~/pages/AbstractStreamEditPage/AccessControlSection/PermissionEditor'
import { Bits, setBits, unsetBits } from '~/parsers/StreamParser'
import { Bits, matchBits, setBits, unsetBits } from '~/parsers/StreamParser'
import UnstyledErrors, { MarketplaceTheme } from '~/shared/components/Ui/Errors'
import UnstyledLabel from '~/shared/components/Ui/Label'
import Text from '~/shared/components/Ui/Text'
Expand All @@ -30,7 +29,7 @@ const Errors = styled(UnstyledErrors)`
`

interface PermissionBits {
account: string
publicKey: string
bits: number
}

Expand All @@ -39,6 +38,10 @@ interface NewStreamPermissionsModalProps extends FormModalProps {
onBeforeSubmit?: (payload: PermissionBits) => void
}

const ethereumAddressRegex = /^0x[0-9a-fA-F]{40}$/
const hexStringRegex = /^0x([0-9a-fA-F]{2})+$/
const zeroAddressRegex = /^(0x)?0+$/

export default function NewStreamPermissionsModal({
onReject,
onResolve,
Expand All @@ -47,40 +50,44 @@ export default function NewStreamPermissionsModal({
}: NewStreamPermissionsModalProps) {
const [permissionBits, setPermissionBits] = useState(0)

const [address, setAddress] = useState('')
const [publicKey, setPublicKey] = useState('')

const [error, setError] = useState('')

const cancelLabel = address || permissionBits !== 0 ? 'Cancel' : undefined
const cancelLabel = publicKey || permissionBits !== 0 ? 'Cancel' : undefined

return (
<FormModal
{...props}
title="Add a new account"
title="Authorize Public Key"
onReject={onReject}
onBeforeAbort={(reason) => {
return (
(permissionBits === 0 && address === '') ||
(permissionBits === 0 && publicKey === '') ||
reason !== RejectionReason.Backdrop
)
}}
onSubmit={() => {
const account = address.toLowerCase()
const normalizedPublicKey = `${publicKey.startsWith('0x') ? '' : '0x'}${publicKey.toLowerCase()}`

if (account === address0) {
return void setError('Invalid address')
if (zeroAddressRegex.test(normalizedPublicKey)) {
return void setError('Invalid key - cannot assign to zero key')
}

if (account.length === 0) {
return void setError('Address required')
if (!hexStringRegex.test(normalizedPublicKey)) {
return void setError('Invalid key - must be a hex string')
}

if (!isAddress(account)) {
return void setError('Invalid address format')
if (!ethereumAddressRegex.test(normalizedPublicKey) && (
matchBits(Bits[StreamPermission.GRANT], permissionBits) ||
matchBits(Bits[StreamPermission.DELETE], permissionBits) ||
matchBits(Bits[StreamPermission.EDIT], permissionBits)
)) {
return void setError('Only Ethereum addresses can have Grant, Edit, or Delete permission')
}

const result = {
account,
publicKey: normalizedPublicKey,
bits: permissionBits,
}

Expand All @@ -96,18 +103,18 @@ export default function NewStreamPermissionsModal({

onResolve?.(result)
}}
canSubmit={!!address}
submitLabel="Add new account"
canSubmit={!!publicKey && permissionBits !== 0}
submitLabel="Authorize Public Key"
cancelLabel={cancelLabel}
>
<div>
<Label>Wallet address</Label>
<Label>Public Key</Label>
<Text
onCommit={(value) => {
setAddress(value)
setPublicKey(value)
setError('')
}}
placeholder="0x…"
placeholder="Hex public key or Ethereum address"
/>
{!!error && (
<Errors theme={MarketplaceTheme} overlap>
Expand All @@ -117,7 +124,7 @@ export default function NewStreamPermissionsModal({
</div>
<Separator />
<PermissionEditor
address={address}
publicKey={publicKey}
permissionBits={permissionBits}
onChange={(permission, enabled) => {
setPermissionBits(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,21 @@ const Label = styled.label<LabelProps>`
type Props = {
operationName: string
value: boolean
address: string
publicKey: string
onChange: (value: boolean) => void
disabled?: boolean
}

const Checkbox: React.FC<Props> = ({
operationName,
value,
address,
publicKey,
onChange,
disabled,
}) => {
const uniqueKey = useMemo(
() => uniqueId(`${operationName}-${address}-`),
[operationName, address],
() => uniqueId(`${operationName}-${publicKey}-`),
[operationName, publicKey],
)

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ const Column = styled.div`
`

type Props = {
address: string
publicKey: string
permissionBits: number
disabled?: boolean
editor?: boolean
onChange: (permission: StreamPermission, enabled: boolean) => void
}

function UnstyledPermissionEditor({
address,
publicKey,
permissionBits,
disabled,
onChange,
Expand All @@ -53,7 +53,7 @@ function UnstyledPermissionEditor({
<span>Read</span>
<Checkbox
operationName="Subscribe"
address={address}
publicKey={publicKey}
value={matchBits(Bits[StreamPermission.SUBSCRIBE], permissionBits)}
onChange={(value) => onChange(StreamPermission.SUBSCRIBE, value)}
disabled={disabled}
Expand All @@ -63,7 +63,7 @@ function UnstyledPermissionEditor({
<span>Write</span>
<Checkbox
operationName="Publish"
address={address}
publicKey={publicKey}
value={matchBits(Bits[StreamPermission.PUBLISH], permissionBits)}
onChange={(value) => onChange(StreamPermission.PUBLISH, value)}
disabled={disabled}
Expand All @@ -73,21 +73,21 @@ function UnstyledPermissionEditor({
<span>Manage</span>
<Checkbox
operationName="Grant"
address={address}
publicKey={publicKey}
value={matchBits(Bits[StreamPermission.GRANT], permissionBits)}
onChange={(value) => onChange(StreamPermission.GRANT, value)}
disabled={disabled}
/>
<Checkbox
operationName="Edit"
address={address}
publicKey={publicKey}
value={matchBits(Bits[StreamPermission.EDIT], permissionBits)}
onChange={(value) => onChange(StreamPermission.EDIT, value)}
disabled={disabled}
/>
<Checkbox
operationName="Delete"
address={address}
publicKey={publicKey}
value={matchBits(Bits[StreamPermission.DELETE], permissionBits)}
onChange={(value) => onChange(StreamPermission.DELETE, value)}
disabled={disabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import UnstyledPermissionEditor from './PermissionEditor'

type Props = {
disabled?: boolean
address: string
publicKey: string
permissionBits: number
}

export function PermissionItem(props: Props) {
const { disabled = false, address, permissionBits } = props
const { disabled = false, publicKey, permissionBits } = props

const [isOpen, setIsOpen] = useState(false)

Expand All @@ -35,12 +35,12 @@ export function PermissionItem(props: Props) {
setIsOpen((prev) => !prev)
}}
>
{isOpen ? address : truncate(address)}
{isOpen ? truncate(publicKey, 50, 20, 20) : truncate(publicKey)}
{isOpen ? (
<div />
) : (
<Labels>
{account?.toLowerCase() === address.toLowerCase() && (
{account?.toLowerCase() === publicKey.toLowerCase() && (
<YouLabel>You</YouLabel>
)}
{operations.map((op) => (
Expand All @@ -54,16 +54,16 @@ export function PermissionItem(props: Props) {
</Title>
{isOpen && (
<PermissionEditor
address={address}
publicKey={publicKey}
permissionBits={permissionBits}
disabled={disabled}
onChange={(permission, enabled) => {
update((hot, cold) => {
if (cold.permissions[address] == null) {
cold.permissions[address] = 0
if (cold.permissions[publicKey] == null) {
cold.permissions[publicKey] = 0
}

hot.permissions[address] = (enabled ? setBits : unsetBits)(
hot.permissions[publicKey] = (enabled ? setBits : unsetBits)(
permissionBits,
Bits[permission],
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ export function PermissionList({ disabled = false }) {
key !== address0 && (
<PermissionItem
key={key}
address={key}
publicKey={key}
permissionBits={bits || 0}
disabled={disabled}
/>
),
)}
<Footer>
<span>
{count} Ethereum account{count === 1 ? '' : 's'}
{count} Public Key{count === 1 ? '' : 's'}
</span>
<Button
kind="primary"
Expand All @@ -42,36 +42,36 @@ export function PermissionList({ disabled = false }) {
outline
onClick={async () => {
try {
const { account, bits } = await toaster(
const { publicKey, bits } = await toaster(
NewStreamPermissionsModal,
Layer.Modal,
).pop({
onBeforeSubmit(payload) {
if (permissions[payload.account] != null) {
if (permissions[payload.publicKey] != null) {
throw new Error(
'Permissions for this address already exist',
'Permissions for this public key already exist',
)
}
},
})

update((hot, cold) => {
if (cold.permissions[account] == null) {
cold.permissions[account] = 0
if (cold.permissions[publicKey] == null) {
cold.permissions[publicKey] = 0
}

hot.permissions[account] = bits
hot.permissions[publicKey] = bits
})
} catch (e) {
if (isAbandonment(e)) {
return
}

console.warn('Could not add permissions for a new account', e)
console.warn('Could not add permissions for a new public key', e)
}
}}
>
Add a new account
Add Public Key
</Button>
</Footer>
</Container>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function StreamTypeSelector({ disabled }: Props) {
<div>
<Title>Private subscribe</Title>
<Description>
Only Ethereum accounts listed below can read/subscribe to the
Only the keys listed below can read/subscribe to the
stream.
</Description>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ export function AccessControlSection({ disabled: disabledProp = false }) {
return (
<Section title="Access control">
<p>
You can make your stream public, or grant access to specific Ethereum
accounts. Learn more about stream access control from the{' '}
<a href={R.docs()}>docs</a>.
You can make your stream public, or grant access to holders of specific cryptographic keys.
Learn more about stream access control and permissions from the{' '}
<a href={R.docs('/usage/streams/permissions/')}>docs</a>.
</p>
<StreamTypeSelector disabled={disabled} />
<PermissionList disabled={disabled} />
Expand Down
21 changes: 20 additions & 1 deletion src/shared/utils/text.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { truncate, truncateStreamName, parseStreamId as psi } from './text'
describe('text utils', () => {
describe('truncate', () => {
it('does not truncate non-strings', () => {
it('does not truncate inputs without 0x prefix', () => {
expect(truncate('123')).toBe('123')
})

it('does not truncate non-hex inputs', () => {
expect(truncate('0xxy')).toBe('0xxy')
})

it('does not truncate hashes that are too short', () => {
expect(truncate('0x0123456789abcdef0123456789abcdef01234567')).toBe(
'0x012...34567',
Expand All @@ -14,6 +18,21 @@ describe('text utils', () => {
)
})

it('has configurable truncateLongerThan threshold', () => {
expect(truncate('0x0123456789abcdef0123456789abcdef01234567890', 100)).toBe(
'0x0123456789abcdef0123456789abcdef01234567890',
)
expect(truncate('0x0123456789abcdef0123456789abcdef01234567890', 40)).toBe(
'0x012...67890',
)
})

it('has configurable pickFirst and pickLast parameters', () => {
expect(truncate('0x0123456789abcdef0123456789abcdef01234567890', 40, 10, 10)).toBe(
'0x01234567...1234567890',
)
})

it('does not truncate non-eth address', () => {
expect(truncate('sandbox/test/my-stream')).toBe('sandbox/test/my-stream')
expect(truncate('FwhuQBTrtfkddf2542asd')).toBe('FwhuQBTrtfkddf2542asd')
Expand Down
Loading