Skip to content
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

feat: delegate access to spaces #293

Merged
merged 7 commits into from
Jan 27, 2023
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: 2 additions & 0 deletions examples/react/w3console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
},
"dependencies": {
"@heroicons/react": "^2.0.13",
"@ipld/car": "^5.0.3",
"@ipld/dag-ucan": "^3.2.0",
"@w3ui/keyring-core": "workspace:^",
"@w3ui/react": "workspace:^",
"@w3ui/react-keyring": "workspace:^",
Expand Down
33 changes: 19 additions & 14 deletions examples/react/w3console/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { useEffect, useState } from 'react'
import { Authenticator, Uploader, UploadsList, W3APIProvider, SpaceFinder } from '@w3ui/react'
import { useKeyring } from '@w3ui/react-keyring'
import { useUploadsList } from '@w3ui/react-uploads-list'
import { ArrowPathIcon } from '@heroicons/react/20/solid'
import { ArrowPathIcon, ShareIcon } from '@heroicons/react/20/solid'
import md5 from 'blueimp-md5'
import '@w3ui/react/src/styles/uploader.css'
import { SpaceShare } from './share'

function SpaceRegistrar (): JSX.Element {
const [, { registerSpace }] = useKeyring()
Expand Down Expand Up @@ -64,6 +65,7 @@ function SpaceRegistrar (): JSX.Element {
}

function SpaceSection (): JSX.Element {
const [share, setShare] = useState(false)
const [{ space }] = useKeyring()
const [, { reload }] = useUploadsList()
// reload the uploads list when the space changes
Expand All @@ -79,27 +81,30 @@ function SpaceSection (): JSX.Element {
{(space !== undefined) && (
<div className='flex flex-row items-start gap-2'>
<img title={space.did()} src={`https://www.gravatar.com/avatar/${md5(space.did())}?d=identicon`} className='w-10 hover:saturate-200 saturate-0 invert border-solid border-gray-500 border' />
<div>
<div className='grow'>
<h1 className='text-xl font-semibold leading-5'>{space.name() ?? 'Untitled'}</h1>
<label className='font-mono text-xs text-gray-500'>{space.did()}</label>
</div>
<button className='h-6 w-6 text-gray-500 hover:text-gray-100' onClick={() => setShare(!share)}>
<ShareIcon />
</button>
</div>
)}

</header>
<div className='container mx-auto'>
{registered
? (
<>
<Uploader onUploadComplete={() => { void reload() }} />
<div className='mt-8'>
<UploadsList />
</div>
</>
)
: (
<SpaceRegistrar />
)}
{share && <SpaceShare />}
{registered && !share && (
<>
<Uploader onUploadComplete={() => { void reload() }} />
<div className='mt-8'>
<UploadsList />
</div>
</>
)}
{!registered && (
<SpaceRegistrar />
)}
</div>
</div>
)
Expand Down
88 changes: 88 additions & 0 deletions examples/react/w3console/src/share.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { ChangeEvent, useState } from 'react'
import { useKeyring } from '@w3ui/react-keyring'
import * as DID from '@ipld/dag-ucan/did'
import { CarWriter } from '@ipld/car/writer'
import type { PropsWithChildren } from 'react'
import type { Delegation } from '@ucanto/interface'

function Header (props: PropsWithChildren): JSX.Element {
return <h3 className='font-semibold text-xs font-mono uppercase tracking-wider mb-4 text-gray-400'>{props.children}</h3>
}

export async function toCarBlob (delegation: Delegation): Promise<Blob> {
const { writer, out } = CarWriter.create()
for (const block of delegation.export()) {
// @ts-expect-error
void writer.put(block)
}
void writer.close()

const carParts = []
for await (const chunk of out) {
carParts.push(chunk)
}
const car = new Blob(carParts, {
type: 'application/vnd.ipld.car'
})
return car
}

export function SpaceShare (): JSX.Element {
const [, { createDelegation }] = useKeyring()
const [value, setValue] = useState('')
const [downloadUrl, setDownloadUrl] = useState('')

async function makeDownloadLink (input: string): Promise<void> {
let audience
try {
audience = DID.parse(input.trim())
} catch (err) {
setDownloadUrl('')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we want to stash this error somewhere so we can tell the user why the DID they pasted is wrong or is there some other mechanism for that? either way not urgent, but probably useful for quality of life at some point

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'll circle back in another PR and improve this!

return
}

try {
const delegation = await createDelegation(audience, ['*'], { expiration: Infinity })
const blob = await toCarBlob(delegation)
const url = URL.createObjectURL(blob)
setDownloadUrl(url)
} catch (err) {
throw new Error('failed to register', { cause: err })
}
}

function onSubmit (e: React.FormEvent<HTMLFormElement>): void {
e.preventDefault()
void makeDownloadLink(value)
}

function onChange (e: ChangeEvent<HTMLInputElement>): void {
const input = e.target.value
void makeDownloadLink(input)
setValue(input)
}

function downloadName (ready: boolean, inputDid: string): string {
if (!ready || inputDid === '') return ''
const [, method = '', id = ''] = inputDid.split(':')
return `did-${method}-${id?.substring(0, 10)}.ucan`
}

return (
<div className='pt-12'>
<div className=''>
<Header>Share your space</Header>
<p className='mb-4'>Ask your friend for their Decentralized Identifier (DID) and paste it in below</p>
<form onSubmit={(e: React.FormEvent<HTMLFormElement>) => { void onSubmit(e) }}>
<input
className='text-black px-2 py-2 rounded block mb-4 w-full max-w-4xl font-mono text-sm'
type='pattern' pattern='did:.+' placeholder='did:'
value={value}
onChange={onChange}
/>
<a className='w3ui-button text-center block w-52 opacity-30' style={{ opacity: downloadUrl !== '' ? '1' : '0.2' }} href={downloadUrl ?? ''} download={downloadName(downloadUrl !== '', value)}>Download UCAN</a>
</form>
</div>
</div>
)
}
11 changes: 9 additions & 2 deletions packages/keyring-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Agent } from '@web3-storage/access/agent'
import { StoreIndexedDB } from '@web3-storage/access/stores/store-indexeddb'
import type { Service } from '@web3-storage/access/types'
import type { Capability, DID, Proof, Signer, ConnectionView, Principal } from '@ucanto/interface'
import type { Abilities, AgentMeta, Service } from '@web3-storage/access/types'
import type { Capability, DID, Proof, Signer, ConnectionView, Principal, Delegation, UCANOptions } from '@ucanto/interface'
import * as RSASigner from '@ucanto/principal/rsa'

const DB_NAME = 'w3ui'
Expand Down Expand Up @@ -108,8 +108,15 @@ export interface KeyringContextActions {
* an audience matching the agent DID.
*/
getProofs: (caps: Capability[]) => Promise<Proof[]>
/**
* Create a delegation to the passed audience for the given abilities with
* the _current_ space as the resource.
*/
createDelegation: (audience: Principal, abilities: Abilities[], options: CreateDelegationOptions) => Promise<Delegation>
}

export type CreateDelegationOptions = Omit<UCANOptions, 'audience'> & { audienceMeta?: AgentMeta }

export interface ServiceConfig {
servicePrincipal?: Principal
connection?: ConnectionView<Service>
Expand Down
3 changes: 2 additions & 1 deletion packages/react-keyring/src/Authenticator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ export const AuthenticatorContext = createContext<AuthenticatorContextValue>([
setCurrentSpace: async () => { },
registerSpace: async () => { },
cancelRegisterSpace: () => { },
getProofs: async () => []
getProofs: async () => [],
createDelegation: async () => { throw new Error('missing keyring context provider') }
}
])

Expand Down
22 changes: 18 additions & 4 deletions packages/react-keyring/src/providers/Keyring.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { createContext, useState, useContext } from 'react'
import { createAgent, Space, getCurrentSpace, getSpaces } from '@w3ui/keyring-core'
import { createAgent, Space, getCurrentSpace, getSpaces, CreateDelegationOptions } from '@w3ui/keyring-core'
import type { KeyringContextState, KeyringContextActions, ServiceConfig } from '@w3ui/keyring-core'
import type { Agent } from '@web3-storage/access'
import type { Capability, DID, Proof, Signer } from '@ucanto/interface'
import type { Abilities } from '@web3-storage/access/types'
import type { Capability, Delegation, DID, Principal, Proof, Signer } from '@ucanto/interface'

export { KeyringContextState, KeyringContextActions }

Expand All @@ -25,7 +26,8 @@ export const keyringContextDefaultValue: KeyringContextValue = [
setCurrentSpace: async () => { },
registerSpace: async () => { },
cancelRegisterSpace: () => { },
getProofs: async () => []
getProofs: async () => [],
createDelegation: async () => { throw new Error('missing keyring context provider') }
}
]

Expand Down Expand Up @@ -116,6 +118,17 @@ export function KeyringProvider ({ children, servicePrincipal, connection }: Key
return agent.proofs(caps)
}

const createDelegation = async (audience: Principal, abilities: Abilities[], options: CreateDelegationOptions): Promise<Delegation> => {
const agent = await getAgent()
const audienceMeta = options.audienceMeta ?? { name: 'agent', type: 'device' }
return await agent.delegate({
...options,
abilities,
audience,
audienceMeta
})
}

const state = {
space,
spaces,
Expand All @@ -129,7 +142,8 @@ export function KeyringProvider ({ children, servicePrincipal, connection }: Key
registerSpace,
cancelRegisterSpace,
setCurrentSpace,
getProofs
getProofs,
createDelegation
}

return (
Expand Down
22 changes: 18 additions & 4 deletions packages/solid-keyring/src/providers/Keyring.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { ParentComponent } from 'solid-js'
import type { KeyringContextState, KeyringContextActions, ServiceConfig } from '@w3ui/keyring-core'
import type { KeyringContextState, KeyringContextActions, ServiceConfig, CreateDelegationOptions } from '@w3ui/keyring-core'
import type { Agent } from '@web3-storage/access'
import type { Delegation, Capability, DID } from '@ucanto/interface'
import type { Abilities } from '@web3-storage/access/types'
import type { Delegation, Capability, DID, Principal } from '@ucanto/interface'

import { createContext, useContext, createSignal, createComponent } from 'solid-js'
import { createStore } from 'solid-js/store'
Expand Down Expand Up @@ -30,7 +31,8 @@ export const AuthContext = createContext<KeyringContextValue>([
setCurrentSpace: async () => { },
registerSpace: async () => { },
cancelRegisterSpace: () => { },
getProofs: async () => []
getProofs: async () => [],
createDelegation: async () => { throw new Error('missing keyring context provider') }
}
])

Expand Down Expand Up @@ -121,6 +123,17 @@ export const KeyringProvider: ParentComponent<KeyringProviderProps> = props => {
return agent.proofs(caps)
}

const createDelegation = async (audience: Principal, abilities: Abilities[], options: CreateDelegationOptions): Promise<Delegation> => {
const agent = await getAgent()
const audienceMeta = options.audienceMeta ?? { name: 'agent', type: 'device' }
return await agent.delegate({
...options,
abilities,
audience,
audienceMeta
})
}

const actions = {
loadAgent,
unloadAgent,
Expand All @@ -129,7 +142,8 @@ export const KeyringProvider: ParentComponent<KeyringProviderProps> = props => {
registerSpace,
cancelRegisterSpace,
setCurrentSpace,
getProofs
getProofs,
createDelegation
}

return createComponent(AuthContext.Provider, {
Expand Down
Loading