| … | … | @@ -0,0 +1,463 @@ |
| 1 | # Hypernote → hyper-identity Integration Plan |
| 2 | |
| 3 | > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. |
| 4 | |
| 5 | **Goal:** Replace hypernote's custom identity/auth implementation with `hyper-identity` library. |
| 6 | |
| 7 | **Architecture:** Hypernote currently has its own parallel auth system (IdentityRegistry, ChallengeManager, RateLimiter, 3 providers) that evolved independently from pear-git's. The `hyper-identity` library extracts pear-git's battle-tested implementation with a generic permission model (`resources` instead of `repos`). This migration replaces hypernote's auth internals with hyper-identity imports while preserving hypernote-specific behavior (notebook permissions, mirror sync, auto-admin bootstrap). |
| 8 | |
| 9 | **Tech Stack:** hyper-identity, autobase, hyperbee, hypercore-crypto, b4a |
| 10 | |
| 11 | --- |
| 12 | |
| 13 | ## Current State |
| 14 | |
| 15 | Hypernote auth files (all under `lib/auth/`): |
| 16 | - `identity-registry.js` — Hyperbee-backed user/identity CRUD (NOT Autobase) |
| 17 | - `challenge-manager.js` — Challenge generation/verification |
| 18 | - `rate-limiter.js` — Token bucket rate limiting |
| 19 | - `base-provider.js` — Abstract provider |
| 20 | - `providers/web-identity.js` — ANProto browser auth |
| 21 | - `providers/keet-identity.js` — BIP39 mnemonic auth |
| 22 | - `providers/bearer-token.js` — API token auth |
| 23 | |
| 24 | Main orchestrator: `lib/auth.js` (AuthManager) |
| 25 | |
| 26 | **Key difference:** Hypernote's IdentityRegistry uses plain Hyperbee (not Autobase). hyper-identity uses Autobase-backed registry. The migration adds multi-writer consensus for free. |
| 27 | |
| 28 | --- |
| 29 | |
| 30 | ## Task 1: Add hyper-identity dependency |
| 31 | |
| 32 | **Files:** |
| 33 | - Modify: `package.json` |
| 34 | |
| 35 | **Step 1:** Add hyper-identity as a local dependency: |
| 36 | ```json |
| 37 | { |
| 38 | "dependencies": { |
| 39 | "hyper-identity": "file:../packages/hyper-identity" |
| 40 | } |
| 41 | } |
| 42 | ``` |
| 43 | |
| 44 | **Step 2:** Install: |
| 45 | ```bash |
| 46 | cd hyper-fleeky/hypernote && npm install |
| 47 | ``` |
| 48 | |
| 49 | **Step 3:** Verify import works: |
| 50 | ```bash |
| 51 | node -e "import('hyper-identity').then(m => console.log(Object.keys(m)))" |
| 52 | ``` |
| 53 | |
| 54 | **Step 4: Commit** |
| 55 | |
| 56 | --- |
| 57 | |
| 58 | ## Task 2: Create hypernote-specific permission adapter |
| 59 | |
| 60 | **Files:** |
| 61 | - Create: `lib/auth/notebook-permissions.js` |
| 62 | |
| 63 | Hypernote uses `notebooks` array on users (not generic `resources`). Create an adapter that maps between hyper-identity's `resources` model and hypernote's `notebooks` model. |
| 64 | |
| 65 | **Step 1:** Write the adapter: |
| 66 | ```javascript |
| 67 | /** |
| 68 | * Notebook permission adapter for hyper-identity |
| 69 | * |
| 70 | * Maps hyper-identity's generic 'resources' to hypernote's 'notebooks'. |
| 71 | * Injected as checkPermission callback into AuthManager. |
| 72 | */ |
| 73 | |
| 74 | /** |
| 75 | * Check if auth has permission for action on notebook |
| 76 | * Injectable into hyper-identity's AuthManager as checkPermission callback. |
| 77 | * |
| 78 | * @param {Object} auth - Auth result from authenticate() |
| 79 | * @param {string} action - 'read', 'write', or 'admin' |
| 80 | * @param {string} notebookName - Notebook slug |
| 81 | * @returns {boolean} |
| 82 | */ |
| 83 | export function checkNotebookPermission (auth, action, notebookName) { |
| 84 | if (!auth || !auth.verified) return false |
| 85 | const perms = auth.permissions |
| 86 | if (perms.admin) return true |
| 87 | if (!perms[action]) return false |
| 88 | if (notebookName) { |
| 89 | return perms.resources.includes('*') || perms.resources.includes(notebookName) |
| 90 | } |
| 91 | return true |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Convert hypernote user's notebooks array to hyper-identity resources format |
| 96 | * Used when creating/updating users in the registry. |
| 97 | * |
| 98 | * @param {Array} notebooks - [{ name: 'slug', permissions: ['read', 'write'] }] |
| 99 | * @returns {Array} resources - Same format (notebooks IS resources in this context) |
| 100 | */ |
| 101 | export function notebooksToResources (notebooks) { |
| 102 | return notebooks // Same shape — just a semantic rename |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * Convert hyper-identity resources back to hypernote notebooks |
| 107 | * Used when reading users from the registry. |
| 108 | */ |
| 109 | export function resourcesToNotebooks (resources) { |
| 110 | return resources // Same shape |
| 111 | } |
| 112 | ``` |
| 113 | |
| 114 | **Step 2: Commit** |
| 115 | |
| 116 | --- |
| 117 | |
| 118 | ## Task 3: Replace AuthManager with hyper-identity's AuthManager |
| 119 | |
| 120 | **Files:** |
| 121 | - Modify: `lib/auth.js` |
| 122 | |
| 123 | This is the main change. Replace hypernote's custom AuthManager with hyper-identity's, adding the notebook permission adapter. |
| 124 | |
| 125 | **Step 1:** Rewrite `lib/auth.js`: |
| 126 | |
| 127 | ```javascript |
| 128 | import { AuthManager as HyperAuthManager, Permissions, sendAuthError } from 'hyper-identity' |
| 129 | import { checkNotebookPermission } from './auth/notebook-permissions.js' |
| 130 | |
| 131 | /** |
| 132 | * HypernoteAuthManager — Extends hyper-identity's AuthManager |
| 133 | * with notebook-specific behavior and mirror sync support. |
| 134 | */ |
| 135 | export class AuthManager extends HyperAuthManager { |
| 136 | constructor (options = {}) { |
| 137 | super({ |
| 138 | ...options, |
| 139 | checkPermission: checkNotebookPermission |
| 140 | }) |
| 141 | |
| 142 | // Shared registry reference for mirror auth fallback |
| 143 | this.sharedRegistry = null |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * Override authenticate to add mirror fallback behavior |
| 148 | */ |
| 149 | async authenticate (req, options = {}) { |
| 150 | // First try standard hyper-identity auth |
| 151 | const auth = await super.authenticate(req, options) |
| 152 | |
| 153 | if (auth) return auth |
| 154 | |
| 155 | // Mirror fallback: if no local user found, check shared registry |
| 156 | if (this.sharedRegistry) { |
| 157 | return this._mirrorFallbackAuth(req, options) |
| 158 | } |
| 159 | |
| 160 | return null |
| 161 | } |
| 162 | |
| 163 | /** |
| 164 | * Mirror fallback — check shared registry for synced users |
| 165 | * Only used when local auth fails and a shared registry is configured. |
| 166 | */ |
| 167 | async _mirrorFallbackAuth (req, options = {}) { |
| 168 | // Extract public key from request |
| 169 | const publicKey = req.headers['x-wirecore-publickey'] || |
| 170 | req.headers['x-identity-publickey'] |
| 171 | if (!publicKey) return null |
| 172 | |
| 173 | try { |
| 174 | // Check if pubkey already maps to a valid local user |
| 175 | const existingIdentity = await this.identityRegistry.getIdentityByPublicKey(publicKey) |
| 176 | if (existingIdentity && existingIdentity.userId) { |
| 177 | const existingUser = await this.identityRegistry.getUser(existingIdentity.userId) |
| 178 | if (existingUser) { |
| 179 | return { |
| 180 | userId: existingUser.userId, |
| 181 | identityId: existingIdentity.identityId || publicKey, |
| 182 | method: 'web-identity', |
| 183 | permissions: this.formatUserPermissions(existingUser, null), |
| 184 | user: existingUser, |
| 185 | verified: true |
| 186 | } |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | // Look up in shared registry |
| 191 | const sharedUser = await this.sharedRegistry.getSharedUserByPublicKey(publicKey) |
| 192 | if (!sharedUser) return null |
| 193 | |
| 194 | // Create local user from shared data |
| 195 | const userObj = { |
| 196 | userId: sharedUser.userId, |
| 197 | username: sharedUser.username || null, |
| 198 | name: sharedUser.name || 'Anonymous', |
| 199 | email: null, |
| 200 | profilePicture: null, |
| 201 | globalPermissions: sharedUser.globalPermissions || ['read'], |
| 202 | resources: sharedUser.resources || sharedUser.notebooks || |
| 203 | [{ name: '*', permissions: ['read'] }], |
| 204 | identities: [publicKey], |
| 205 | createdAt: Date.now(), |
| 206 | updatedAt: Date.now() |
| 207 | } |
| 208 | |
| 209 | // Store in identity registry |
| 210 | if (this.identityRegistry.db) { |
| 211 | await this.identityRegistry.db.put(`user:${sharedUser.userId}`, userObj) |
| 212 | } |
| 213 | |
| 214 | // Link identity |
| 215 | const identity = await this.identityRegistry.getIdentity(publicKey) |
| 216 | if (identity) { |
| 217 | identity.userId = sharedUser.userId |
| 218 | if (this.identityRegistry.db) { |
| 219 | await this.identityRegistry.db.put(`identity:${publicKey}`, identity) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | return { |
| 224 | userId: sharedUser.userId, |
| 225 | identityId: publicKey, |
| 226 | method: 'web-identity', |
| 227 | permissions: this.formatUserPermissions(userObj, null), |
| 228 | user: userObj, |
| 229 | verified: true |
| 230 | } |
| 231 | } catch { |
| 232 | return null |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | // Backward compat: hasNotebookAccess wraps hasResourceAccess |
| 237 | hasNotebookAccess (auth, notebookName, action = 'read') { |
| 238 | return this.hasResourceAccess(auth, notebookName, action) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | export { Permissions, sendAuthError } |
| 243 | ``` |
| 244 | |
| 245 | **Step 2:** Update all callers of `hasNotebookAccess` — should still work via the backward compat wrapper. |
| 246 | |
| 247 | **Step 3: Commit** |
| 248 | |
| 249 | --- |
| 250 | |
| 251 | ## Task 4: Replace IdentityRegistry with hyper-identity's |
| 252 | |
| 253 | **Files:** |
| 254 | - Modify: `lib/auth.js` (constructor) |
| 255 | - Delete: `lib/auth/identity-registry.js` (replaced by hyper-identity) |
| 256 | |
| 257 | **Step 1:** In AuthManager constructor, use hyper-identity's IdentityRegistry: |
| 258 | |
| 259 | ```javascript |
| 260 | import { IdentityRegistry } from 'hyper-identity/auth/identity-registry' |
| 261 | |
| 262 | // In constructor: |
| 263 | this.identityRegistry = new IdentityRegistry(store, { |
| 264 | db: options.registry, // hypernote's existing Hyperbee |
| 265 | prefix: '' |
| 266 | }) |
| 267 | await this.identityRegistry.ready() |
| 268 | ``` |
| 269 | |
| 270 | **Step 2:** Data migration — existing hypernote identity data uses the same key schema (`user:`, `identity:`, `pubkey:`), so no migration needed for direct-write data. The Autobase view is new — it will build from ops going forward. |
| 271 | |
| 272 | **Step 3:** Verify existing users/identities still resolve correctly. |
| 273 | |
| 274 | **Step 4: Commit** |
| 275 | |
| 276 | --- |
| 277 | |
| 278 | ## Task 5: Replace auth providers with hyper-identity's |
| 279 | |
| 280 | **Files:** |
| 281 | - Delete: `lib/auth/providers/web-identity.js` |
| 282 | - Delete: `lib/auth/providers/keet-identity.js` |
| 283 | - Delete: `lib/auth/providers/bearer-token.js` |
| 284 | - Delete: `lib/auth/base-provider.js` |
| 285 | - Modify: `lib/auth.js` (imports) |
| 286 | |
| 287 | **Step 1:** Remove local provider files. |
| 288 | |
| 289 | **Step 2:** Update imports in auth.js to use hyper-identity providers (already done in Task 3 since the new AuthManager auto-registers them). |
| 290 | |
| 291 | **Step 3:** Verify all three auth methods work: |
| 292 | - Web identity: Browser sends `X-Wirecore-PublicKey` header → auth succeeds |
| 293 | - Bearer token: `Authorization: Bearer <token>` → auth succeeds |
| 294 | - Keet identity: `X-Keet-PublicKey` header → auth succeeds |
| 295 | |
| 296 | **Step 4: Commit** |
| 297 | |
| 298 | --- |
| 299 | |
| 300 | ## Task 6: Replace ChallengeManager and RateLimiter |
| 301 | |
| 302 | **Files:** |
| 303 | - Delete: `lib/auth/challenge-manager.js` |
| 304 | - Delete: `lib/auth/rate-limiter.js` |
| 305 | - Modify: `lib/auth.js` (already uses hyper-identity's versions) |
| 306 | |
| 307 | **Step 1:** Remove local files. |
| 308 | |
| 309 | **Step 2:** Verify challenge-response flow: |
| 310 | ``` |
| 311 | GET /api/auth/challenge → { challengeId, challenge } |
| 312 | # Client signs challenge with ANProto |
| 313 | # Client sends signed request with X-Wirecore-Challenge header |
| 314 | ``` |
| 315 | |
| 316 | **Step 3:** Verify rate limiting still works (check with rapid requests). |
| 317 | |
| 318 | **Step 4: Commit** |
| 319 | |
| 320 | --- |
| 321 | |
| 322 | ## Task 7: Update hypernote-server.js initialization |
| 323 | |
| 324 | **Files:** |
| 325 | - Modify: `hypernote-server.js` |
| 326 | |
| 327 | **Step 1:** Update AuthManager initialization to use new constructor: |
| 328 | |
| 329 | ```javascript |
| 330 | import { AuthManager } from './lib/auth.js' |
| 331 | import { IdentityRegistry } from 'hyper-identity/auth/identity-registry' |
| 332 | |
| 333 | // In server init: |
| 334 | const identityRegistry = new IdentityRegistry(this.store, { |
| 335 | db: this.registryBee |
| 336 | }) |
| 337 | await identityRegistry.ready() |
| 338 | |
| 339 | this.authManager = new AuthManager({ |
| 340 | identityRegistry, |
| 341 | db: this.registryBee, |
| 342 | trustedProxies: ['127.0.0.1', '::1', '::ffff:127.0.0.1'], |
| 343 | rateLimits: { |
| 344 | auth: { tokens: 1000, refillRate: 100, refillInterval: 60 * 1000 }, |
| 345 | api: { tokens: 10000, refillRate: 1000, refillInterval: 60 * 1000 }, |
| 346 | notes: { tokens: 5000, refillRate: 500, refillInterval: 60 * 1000 }, |
| 347 | 'identity-generate': { tokens: 5, refillRate: 1, refillInterval: 60 * 60 * 1000 } |
| 348 | } |
| 349 | }) |
| 350 | ``` |
| 351 | |
| 352 | **Step 2:** Pass `sharedRegistry` reference for mirror sync: |
| 353 | ```javascript |
| 354 | if (this.multiwriter && this.multiwriter.registry) { |
| 355 | this.authManager.sharedRegistry = this.multiwriter.registry |
| 356 | } |
| 357 | ``` |
| 358 | |
| 359 | **Step 3:** Update `_deduplicateIdentities()` to use hyper-identity's `mergeAccounts()` method instead of manual Hyperbee manipulation. |
| 360 | |
| 361 | **Step 4: Commit** |
| 362 | |
| 363 | --- |
| 364 | |
| 365 | ## Task 8: Update multiwriter manager to use hyper-identity registry |
| 366 | |
| 367 | **Files:** |
| 368 | - Modify: `lib/multiwriter/manager.js` |
| 369 | |
| 370 | **Step 1:** The multiwriter manager's identity sync (`_syncIdentities`) currently reads user records and pushes them to the shared Autobase registry. Update to use hyper-identity's IdentityRegistry methods instead of raw Hyperbee reads. |
| 371 | |
| 372 | **Step 2:** The pull sync that imports users from shared registry should use hyper-identity's `createUser()` instead of raw `db.put()`. |
| 373 | |
| 374 | **Step 3: Commit** |
| 375 | |
| 376 | --- |
| 377 | |
| 378 | ## Task 9: Update routes to use new auth API |
| 379 | |
| 380 | **Files:** |
| 381 | - Modify: `lib/routes/notebooks.js` |
| 382 | - Modify: `lib/routes/multiwriter.js` |
| 383 | - Modify: `lib/routes/web.js` |
| 384 | |
| 385 | **Step 1:** The `requireAuth` helper in notebooks.js calls `server.authManager.authorizeRequest()` — this API is preserved, no changes needed. |
| 386 | |
| 387 | **Step 2:** Check all routes that call `authManager.identityRegistry.*` directly and update method names if any changed. |
| 388 | |
| 389 | **Step 3:** The multiwriter routes that call `authManager.identityRegistry.getUser()`, `getUserByUsername()`, etc. — verify these methods exist on hyper-identity's IdentityRegistry. |
| 390 | |
| 391 | **Step 4: Commit** |
| 392 | |
| 393 | --- |
| 394 | |
| 395 | ## Task 10: Cleanup and final verification |
| 396 | |
| 397 | **Files:** |
| 398 | - Delete: `lib/auth/identity-registry.js` (if not already deleted) |
| 399 | - Delete: `lib/auth/challenge-manager.js` |
| 400 | - Delete: `lib/auth/rate-limiter.js` |
| 401 | - Delete: `lib/auth/base-provider.js` |
| 402 | - Delete: `lib/auth/providers/` directory (all 3 providers) |
| 403 | |
| 404 | **Step 1:** Remove all replaced files. |
| 405 | |
| 406 | **Step 2:** Verify no dangling imports: |
| 407 | ```bash |
| 408 | grep -r "from.*\./auth/" lib/ --include="*.js" | grep -v node_modules |
| 409 | ``` |
| 410 | |
| 411 | **Step 3:** Start server and test: |
| 412 | - Anonymous read: `curl http://localhost:4040/api/notebooks` → 200 |
| 413 | - Auth challenge: `curl http://localhost:4040/api/auth/challenge` → challengeId |
| 414 | - Bearer token: `curl -H "Authorization: Bearer <token>" http://localhost:4040/api/notebooks?mine=true` → filtered list |
| 415 | - Admin panel: Open browser, verify identity auto-registration works |
| 416 | |
| 417 | **Step 4:** Test mirror sync between tabertron and stortron: |
| 418 | - Create notebook on one instance |
| 419 | - Verify it appears on the other after sync |
| 420 | - Verify auth works on both instances |
| 421 | |
| 422 | **Step 5: Commit** |
| 423 | |
| 424 | --- |
| 425 | |
| 426 | ## Migration Notes |
| 427 | |
| 428 | ### Data Compatibility |
| 429 | - Existing `user:`, `identity:`, `pubkey:` key schemas are preserved — no data migration needed |
| 430 | - hyper-identity adds Autobase on top but the direct-write Hyperbee (`db`) stores identity records in the same format |
| 431 | - Bearer token records (`token:*`) are compatible |
| 432 | |
| 433 | ### Breaking Changes |
| 434 | - None for HTTP API consumers (same headers, same endpoints) |
| 435 | - None for web UI (same challenge-response flow) |
| 436 | - Internal: `user.notebooks` → `user.resources` in registry, but the permission adapter handles the mapping |
| 437 | |
| 438 | ### Rollback |
| 439 | If issues arise, revert to the commit before Task 3. The old auth files can be restored from git. |
| 440 | |
| 441 | ### Benefits After Migration |
| 442 | 1. **Autobase consensus** — multi-writer identity management without manual dedup |
| 443 | 2. **Shared codebase** — pear-git and hypernote use same identity library |
| 444 | 3. **Device linking** — DeviceLinker + SecureStorage for mnemonic exchange |
| 445 | 4. **Future-proof** — any new app can use hyper-identity for auth |
| 446 | |
| 447 | --- |
| 448 | |
| 449 | ## Key Files |
| 450 | |
| 451 | | File | Action | |
| 452 | |------|--------| |
| 453 | | `package.json` | Add hyper-identity dependency | |
| 454 | | `lib/auth.js` | Rewrite to extend hyper-identity AuthManager | |
| 455 | | `lib/auth/notebook-permissions.js` | New: permission adapter | |
| 456 | | `lib/auth/identity-registry.js` | Delete (replaced) | |
| 457 | | `lib/auth/challenge-manager.js` | Delete (replaced) | |
| 458 | | `lib/auth/rate-limiter.js` | Delete (replaced) | |
| 459 | | `lib/auth/base-provider.js` | Delete (replaced) | |
| 460 | | `lib/auth/providers/*.js` | Delete (replaced) | |
| 461 | | `hypernote-server.js` | Update initialization | |
| 462 | | `lib/multiwriter/manager.js` | Update identity sync | |
| 463 | | `lib/routes/*.js` | Verify API compatibility | |