feat: extend private address books to OIDC users#3083
Conversation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR extends private address book + address book request write operations to OIDC-authenticated users by removing SiWE-only write gates, migrating attribution columns from wallet strings to users.id, and resolving display identity (wallet/email/fallback) at read time via a shared resolver.
Changes:
- Introduces
UserIdentityResolverService(+ module + unit tests) and refactors shared space address book identity mapping to use it. - Removes SiWE-only guards from private address book + request write flows; updates DTOs to expose both identity strings and corresponding
*UserIdfields. - Applies a DB migration to drop wallet-string attribution columns and add
reviewed_by_idwith FK semantics; updates repositories/entities and adds OIDC E2E coverage.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/modules/users/domain/user-identity-resolver.service.ts | New shared service to resolve user display identities (wallet/email/fallback). |
| src/modules/users/domain/user-identity-resolver.module.ts | Nest module exporting the resolver for reuse across spaces routes. |
| src/modules/users/domain/tests/user-identity-resolver.service.spec.ts | Unit tests covering resolver behavior and fallbacks. |
| src/modules/spaces/spaces.module.ts | Wires the resolver module into SpacesModule; removes now-unneeded direct wallets repo provider. |
| src/modules/spaces/routes/user-address-book.service.ts | Removes SiWE-only write restriction; maps createdBy from resolved identity and adds createdByUserId. |
| src/modules/spaces/routes/user-address-book.controller.ts | Updates OpenAPI forbidden descriptions (drops “wallet authentication required”). |
| src/modules/spaces/routes/user-address-book.controller.e2e-spec.ts | Adds/updates E2E assertions for createdByUserId and OIDC write/read/delete paths. |
| src/modules/spaces/routes/entities/space-address-book.dto.entity.ts | Updates createdBy semantics docs and adds createdByUserId to DTO. |
| src/modules/spaces/routes/entities/address-book-request.dto.entity.ts | Adds requestedByUserId, reviewedBy, reviewedByUserId and updates field descriptions. |
| src/modules/spaces/routes/address-books.service.ts | Refactors shared space address book identity resolution to use UserIdentityResolverService. |
| src/modules/spaces/routes/address-books.service.spec.ts | Updates unit test wiring to construct AddressBooksService with the new resolver. |
| src/modules/spaces/routes/address-book-requests.service.ts | Removes SiWE-only restriction; switches reviewer attribution to userId and resolves identity strings at read time. |
| src/modules/spaces/routes/address-book-requests.controller.ts | Updates OpenAPI forbidden descriptions (drops “wallet authentication required”). |
| src/modules/spaces/routes/address-book-requests.controller.e2e-spec.ts | Adds OIDC E2E coverage for create/approve/reject and asserts new DTO fields. |
| src/modules/spaces/domain/address-books/user-address-book-items.repository.ts | Drops signer-address attribution on insert; relies on creator user FK only. |
| src/modules/spaces/domain/address-books/user-address-book-items.repository.interface.ts | Updates repository contract to remove signerAddress. |
| src/modules/spaces/domain/address-books/entities/user-address-book-item.entity.ts | Updates domain schema to remove createdBy wallet field. |
| src/modules/spaces/domain/address-books/entities/address-book-request.entity.ts | Updates domain schema to remove requestedByWallet and switch to reviewedById. |
| src/modules/spaces/domain/address-books/address-book-requests.repository.ts | Removes requestedByWallet, switches reviewedBy to reviewedById in transitions. |
| src/modules/spaces/domain/address-books/address-book-requests.repository.interface.ts | Updates repository contract for new attribution fields. |
| src/modules/spaces/datasources/entities/user-address-book-item.entity.db.ts | Removes created_by column mapping. |
| src/modules/spaces/datasources/entities/address-book-request.entity.db.ts | Removes requested_by_wallet + wallet reviewer column mapping; adds reviewedById. |
| migrations/1778000000000-private-address-book-attribution-to-user-id.ts | Migration dropping wallet-string attribution columns and adding reviewed_by_id FK. |
| docs/superpowers/specs/2026-05-07-oidc-private-address-books-design.md | Design spec documenting the approach and schema/service/DTO changes. |
| docs/superpowers/plans/2026-05-07-oidc-private-address-books.md | Implementation plan artifact for the change set. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const [users, wallets] = await Promise.all([ | ||
| this.usersRepository.find({ id: In(unique) }), | ||
| this.walletsRepository.find({ | ||
| where: { user: { id: In(unique) } }, | ||
| relations: { user: true }, | ||
| }), | ||
| ]); | ||
|
|
||
| const walletByUserId = new Map<number, string>(); | ||
| for (const wallet of wallets) { | ||
| if (!walletByUserId.has(wallet.user.id)) { | ||
| walletByUserId.set(wallet.user.id, wallet.address); | ||
| } | ||
| } |
| - DROP COLUMN `requested_by_wallet` (`varchar(42)`). | ||
| - DROP COLUMN `reviewed_by` (`varchar(42)`), ADD COLUMN `reviewed_by_id integer NULL` with FK to `users(id)` `ON DELETE SET NULL`. | ||
|
|
||
| The TypeORM entity files (`user-address-book-item.entity.db.ts`, `address-book-request.entity.db.ts`) are updated to match: drop the wallet `@Column`s, add a `@ManyToOne(() => User)` for `reviewedBy`. |
| 2. User has a verified OIDC email → return that email. | ||
| 3. User has a wallet → return the preferred/first wallet address. |
- Sort wallets by id ASC in UserIdentityResolverService.resolveMany so users with multiple wallets always render the same display address - Update the design doc to match the implemented resolution order (wallet → email → Unknown) and the raw-integer reviewed_by_id column (no @manytoone relation), matching the space-AB precedent Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| @@ -0,0 +1,1708 @@ | |||
| <!-- | |||
There was a problem hiding this comment.
I have a preference for keeping planning documents out of the repo, as these are one-time, task-specific documents, with many lines.
There was a problem hiding this comment.
Kept it for context for reviewers, will remove at the end.
vseehausen
left a comment
There was a problem hiding this comment.
LGTM, but pls remove the plan files.
LucieFaire
left a comment
There was a problem hiding this comment.
small remarks, otherwise good. Thank you for extracting the resolver!
|
|
||
| await queryRunner.query(` | ||
| ALTER TABLE address_book_requests | ||
| ADD COLUMN reviewed_by_id integer; |
There was a problem hiding this comment.
nit, but i think we could have kept it reviewied_by and just switched the type, as we have with other fields: invited_by,created_by
| }); | ||
|
|
||
| return this.mapRequestItem(request); | ||
| return await this.mapSingleRequest(request); |
There was a problem hiding this comment.
wonder if we really need a separate method or we can reuse the mapRequestItem as it was? we do have spaceid available
|
|
||
| it('falls back to email when no wallet', async () => { | ||
| mockUsersRepository.find.mockResolvedValue([ | ||
| { id: 2, email: 'c@d.com' } as never, |
There was a problem hiding this comment.
could we maybe use a proper type instead of never in these tests?
Summary
user_address_book_items.created_by,address_book_requests.requested_by_wallet/reviewed_by) withusers.idreferences; mirrors the recent space-AB migration (#3056)Unknown user/Deleted userfallbacks) at read time via a new sharedUserIdentityResolverServiceBehavior change
The five write endpoints below previously returned
403 Forbiddenfor OIDC auth and now accept it (member/admin assertions still apply):PUT /v1/spaces/:spaceId/address-book/privateDELETE /v1/spaces/:spaceId/address-book/private/:addressPOST /v1/spaces/:spaceId/address-book/requestsPUT /v1/spaces/:spaceId/address-book/requests/:id/approvePUT /v1/spaces/:spaceId/address-book/requests/:id/rejectDTO changes (pre-prod, single-version, no
v2):UserAddressBookItemDto.createdBysemantic shifts from "wallet" to "identity string" (email/wallet/fallback). Field name unchanged. AddscreatedByUserId: number.AddressBookRequestItemDto.requestedBysame shift; addsrequestedByUserId: number,reviewedBy: string | null,reviewedByUserId: number | null.For SiWE users the identity string still resolves to the wallet, so observable output is unchanged.
Schema migration
migrations/1778000000000-private-address-book-attribution-to-user-id.ts:user_address_book_items.created_byaddress_book_requests.requested_by_walletandreviewed_byaddress_book_requests.reviewed_by_id integer NULLwith FK tousers(id) ON DELETE SET NULLPre-prod, no backfill. The reverse migration restores the dropped columns as nullable
varchar(42).Design and plan
Committed alongside the implementation:
docs/superpowers/specs/2026-05-07-oidc-private-address-books-design.mddocs/superpowers/plans/2026-05-07-oidc-private-address-books.mdHappy to drop these from the merge if the team prefers PR-only artifacts.
Test plan
Unit suite:
yarn test:unit— 4755 passed locally.End-to-end (require Postgres via Docker — please verify in CI):
createdBy,createdByUserId)requestedBy,requestedByUserId,reviewedBy: null,reviewedByUserId: null)createdByUserId(member) andlastUpdatedByUserId(admin)createdBy/requestedByresolve to wallet (regression)up/revert/upround-trip cleanlyFollow-ups (non-blocking, surfaced by the final reviewer)
createdByUserIdequals the requesting member's idrejectreturns 400 instead of 404 for missing request id (pre-existing pattern)UserAddressBookService/AddressBookRequestsService(covered by e2e + repository specs)🤖 Generated with Claude Code