work-in-progress atproto PDS
typescript atproto pds atcute
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: update and reverify handle

Mary e2cef19c 02152b63

+259 -16
+150 -13
packages/danaus/src/web/account/forms.ts
··· 1 + import { signOperation, type UnsignedOperation } from '@atcute/did-plc'; 2 + import type { Did, Handle } from '@atcute/lexicons'; 3 + import { isHandle } from '@atcute/lexicons/syntax'; 1 4 import { XRPCError } from '@atcute/xrpc-server'; 2 5 3 6 import { HTTPException } from 'hono/http-exception'; ··· 7 10 import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '#app/accounts/passwords.ts'; 8 11 import { readWebSessionToken, setWebSessionToken, verifyWebSessionToken } from '#app/auth/web.ts'; 9 12 import type { AppContext } from '#app/context.ts'; 13 + import { isHostnameSuffix } from '#app/utils/schema.ts'; 10 14 11 15 import { form, getRequestContext, invalid, redirect } from '../forms/index.ts'; 12 16 13 17 export const createAccountForms = (ctx: AppContext) => { 14 18 const { accountManager } = ctx; 15 19 16 - // #region verify credentials helper 17 20 const verifyCredentials = () => { 18 21 const c = getRequestContext(); 19 22 const token = readWebSessionToken(c.req.raw); ··· 39 42 40 43 return session; 41 44 }; 42 - // #endregion 43 45 44 - // #region sign-in form 45 46 /** 46 47 * validates credentials, creates session, sets cookie, and redirects. 47 48 */ ··· 80 81 redirect(302, data.redirect ?? '/account'); 81 82 }, 82 83 ); 83 - // #endregion 84 84 85 - // #region create app password form 86 85 /** 87 86 * creates an app password and returns the secret for display. 88 87 */ ··· 91 90 name: v.pipe(v.string(), v.minLength(1, `Name is required`), v.maxLength(32, `Name is too long`)), 92 91 privilege: v.picklist(['limited', 'privileged', 'full'], `Invalid privilege`), 93 92 }), 94 - async (data, issue) => { 93 + async (data) => { 95 94 const session = verifyCredentials(); 96 95 const privilege = parseAppPasswordPrivilege(data.privilege); 97 96 ··· 107 106 if (err instanceof XRPCError && err.status === 400) { 108 107 switch (err.error) { 109 108 case 'DuplicateAppPassword': { 110 - invalid(issue.name(`An app password with this name already exists`)); 109 + invalid(`An app password with this name already exists`); 111 110 } 112 111 case 'TooManyAppPasswords': { 113 - invalid(issue.name(`You've reached the maximum amount of app passwords allowed`)); 112 + invalid(`You've reached the maximum amount of app passwords allowed`); 114 113 } 115 114 } 116 115 } ··· 119 118 } 120 119 }, 121 120 ); 122 - // #endregion 123 121 124 - // #region delete app password form 125 122 /** 126 123 * deletes an app password and redirects back to the list. 127 124 */ ··· 133 130 const session = verifyCredentials(); 134 131 135 132 accountManager.deleteAppPassword(session.did, data.name); 136 - redirect(302, '/account/app-passwords'); 133 + }, 134 + ); 135 + 136 + /** 137 + * updates the account handle, including PLC document for did:plc accounts. 138 + */ 139 + const updateHandleForm = form( 140 + v.object({ 141 + domain: v.union([v.pipe(v.string(), v.check(isHostnameSuffix)), v.literal('custom')]), 142 + handle: v.pipe(v.string(), v.minLength(1, `Handle is required`)), 143 + }), 144 + async (data) => { 145 + const { did } = verifyCredentials(); 146 + 147 + let handle: Handle; 148 + if (data.domain === 'custom') { 149 + if (!isHandle(data.handle)) { 150 + invalid(`Invalid handle`); 151 + } 152 + 153 + handle = data.handle; 154 + } else { 155 + const fullHandle = `${data.handle}${data.domain}`; 156 + if (!isHandle(fullHandle)) { 157 + invalid(`Invalid handle`); 158 + } 159 + 160 + handle = fullHandle; 161 + } 162 + 163 + // validate the handle (checks TLD, service domain constraints, external domain resolution) 164 + try { 165 + handle = await accountManager.validateHandle(handle, { did }); 166 + } catch (err) { 167 + if (err instanceof XRPCError && err.status === 400) { 168 + switch (err.error) { 169 + case 'InvalidHandle': { 170 + invalid(err.description ?? `Invalid handle`); 171 + } 172 + case 'UnsupportedDomain': { 173 + invalid(`Handle must resolve to your DID via DNS or .well-known`); 174 + } 175 + } 176 + } 177 + throw err; 178 + } 179 + 180 + // check if handle is already taken by another account 181 + const existing = accountManager.getAccount(handle, { 182 + includeDeactivated: true, 183 + includeTakenDown: true, 184 + }); 185 + 186 + if (existing !== null) { 187 + if (existing.did === did) { 188 + return; 189 + } 190 + 191 + invalid(`Handle is already taken`); 192 + } 193 + 194 + // update PLC document for did:plc accounts 195 + if (did.startsWith('did:plc:')) { 196 + await updatePlcHandle(ctx, did as Did<'plc'>, handle); 197 + } 198 + 199 + // update local database and emit identity event 200 + accountManager.updateAccountHandle(did, handle); 201 + await ctx.sequencer.emitIdentity(did, handle); 137 202 }, 138 203 ); 139 - // #endregion 204 + 205 + /** 206 + * triggers identity event to refresh handle caches after verifying handle still resolves. 207 + */ 208 + const refreshHandleForm = form(v.object({}), async () => { 209 + const { did } = verifyCredentials(); 210 + 211 + const account = accountManager.getAccount(did)!; 212 + if (!account.handle) { 213 + invalid(`Handle not set`); 214 + } 215 + 216 + // verify handle still resolves correctly 217 + try { 218 + await accountManager.validateHandle(account.handle, { did }); 219 + } catch (err) { 220 + if (err instanceof XRPCError && err.status === 400) { 221 + switch (err.error) { 222 + case 'InvalidHandle': { 223 + invalid(err.description ?? `Handle is no longer valid`); 224 + } 225 + case 'UnsupportedDomain': { 226 + invalid(`Handle no longer resolves to your DID`); 227 + } 228 + } 229 + } 230 + throw err; 231 + } 140 232 141 - return { signInForm, createAppPasswordForm, deleteAppPasswordForm }; 233 + // emit identity event with current handle to trigger cache refresh 234 + await ctx.sequencer.emitIdentity(did, account.handle); 235 + }); 236 + 237 + return { 238 + signInForm, 239 + createAppPasswordForm, 240 + deleteAppPasswordForm, 241 + updateHandleForm, 242 + refreshHandleForm, 243 + }; 142 244 }; 245 + 246 + /** 247 + * updates the handle in a did:plc document. 248 + * @param ctx app context 249 + * @param did the did:plc to update 250 + * @param handle the new handle 251 + */ 252 + async function updatePlcHandle(ctx: AppContext, did: Did<'plc'>, handle: Handle): Promise<void> { 253 + const { plcClient, config } = ctx; 254 + 255 + // get current state and last operation CID 256 + const auditLog = await plcClient.getAuditLog(did); 257 + const lastEntry = auditLog[auditLog.length - 1]; 258 + if (!lastEntry) { 259 + throw new Error(`no operations found for ${did}`); 260 + } 261 + 262 + const state = await plcClient.getState(did); 263 + 264 + // build update operation with new handle 265 + const unsignedOp: UnsignedOperation = { 266 + type: 'plc_operation', 267 + prev: lastEntry.cid, 268 + alsoKnownAs: [`at://${handle}`], 269 + rotationKeys: state.rotationKeys, 270 + verificationMethods: state.verificationMethods, 271 + services: state.services, 272 + }; 273 + 274 + // sign with PDS rotation key 275 + const signedOp = await signOperation(unsignedOp, config.secrets.plcRotationKey); 276 + 277 + // submit to PLC directory 278 + await plcClient.submitOperation(did, signedOp); 279 + }
+109 -3
packages/danaus/src/web/account/index.tsx
··· 138 138 // #endregion 139 139 140 140 // #region overview route 141 - app.get('/', (c) => { 141 + app.on(['GET', 'POST'], '/', (c) => { 142 142 const session = verifyCredentials(c); 143 143 const account = ctx.accountManager.getAccount(session.did); 144 + const { updateHandleForm, refreshHandleForm } = forms; 145 + 146 + // determine current handle parts for form prefill 147 + const currentHandle = account?.handle ?? ''; 148 + const isServiceHandle = ctx.config.identity.serviceHandleDomains.some((d) => currentHandle.endsWith(d)); 149 + const currentDomain = isServiceHandle 150 + ? (ctx.config.identity.serviceHandleDomains.find((d) => currentHandle.endsWith(d)) ?? 'custom') 151 + : 'custom'; 152 + const currentLocalPart = isServiceHandle ? currentHandle.slice(0, -currentDomain.length) : currentHandle; 144 153 145 154 return c.render( 146 155 <AccountLayout> ··· 171 180 172 181 <MenuPopover> 173 182 <MenuList> 174 - <MenuItem>Change handle</MenuItem> 175 - <MenuItem>Request refresh</MenuItem> 183 + <Dialog> 184 + <DialogTrigger> 185 + <MenuItem>Change handle</MenuItem> 186 + </DialogTrigger> 187 + 188 + <DialogSurface> 189 + <DialogBody> 190 + <DialogTitle>Change handle</DialogTitle> 191 + 192 + <form {...updateHandleForm} class="contents"> 193 + <DialogContent class="flex flex-col gap-4"> 194 + <p class="text-base-300 text-neutral-foreground-3"> 195 + Your handle is your unique identity on the AT Protocol network. 196 + </p> 197 + 198 + <Field 199 + label="Domain" 200 + validationMessageText={ 201 + updateHandleForm.fields.domain.issues()[0]?.message 202 + } 203 + > 204 + <Select 205 + {...updateHandleForm.fields.domain.as('select')} 206 + value={updateHandleForm.fields.domain.value() || currentDomain} 207 + options={[ 208 + ...ctx.config.identity.serviceHandleDomains.map((d) => ({ 209 + value: d, 210 + label: d, 211 + })), 212 + { value: 'custom', label: 'I have my own domain' }, 213 + ]} 214 + /> 215 + </Field> 216 + 217 + <Field 218 + label="Handle" 219 + required 220 + validationMessageText={ 221 + updateHandleForm.fields.handle.issues()[0]?.message 222 + } 223 + > 224 + <Input 225 + {...updateHandleForm.fields.handle.as('text')} 226 + value={updateHandleForm.fields.handle.value() || currentLocalPart} 227 + placeholder="alice" 228 + required 229 + /> 230 + </Field> 231 + 232 + <p class="text-base-200 text-neutral-foreground-3"> 233 + Custom domains must have a DNS TXT record or .well-known file pointing to 234 + your DID. 235 + </p> 236 + </DialogContent> 237 + 238 + <DialogActions> 239 + <DialogClose> 240 + <Button>Cancel</Button> 241 + </DialogClose> 242 + 243 + <Button type="submit" variant="primary"> 244 + Save 245 + </Button> 246 + </DialogActions> 247 + </form> 248 + </DialogBody> 249 + </DialogSurface> 250 + </Dialog> 251 + 252 + <Dialog> 253 + <DialogTrigger> 254 + <MenuItem>Request refresh</MenuItem> 255 + </DialogTrigger> 256 + 257 + <DialogSurface> 258 + <DialogBody> 259 + <DialogTitle>Request handle refresh</DialogTitle> 260 + 261 + <form {...refreshHandleForm} class="contents"> 262 + <DialogContent> 263 + <p class="text-base-300"> 264 + This will notify the network to re-verify your handle. Use this if apps 265 + are marking your handle as invalid despite being set up correctly. 266 + </p> 267 + </DialogContent> 268 + 269 + <DialogActions> 270 + <DialogClose> 271 + <Button>Cancel</Button> 272 + </DialogClose> 273 + 274 + <Button type="submit" variant="primary"> 275 + Refresh 276 + </Button> 277 + </DialogActions> 278 + </form> 279 + </DialogBody> 280 + </DialogSurface> 281 + </Dialog> 176 282 </MenuList> 177 283 </MenuPopover> 178 284 </Menu>