Conversation
Deploying happychain with
|
| Latest commit: |
f7764cc
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4795d85d.happychain.pages.dev |
| Branch Preview URL: | https://gabriel-sync-config.happychain.pages.dev |
cacfaa9 to
b457552
Compare
| hex += b.toString(16).padStart(2, '0'); | ||
| } | ||
| return '0x' + hex; | ||
| } |
There was a problem hiding this comment.
Seems to be leftover code to be removed, but in any case, viem has a function for this.
| "compilerOptions": { | ||
| "strict": true | ||
| }, | ||
| "include": ["src", "./package.json"] |
There was a problem hiding this comment.
| "include": ["src", "./package.json"] | |
| "include": ["src"] |
There was a problem hiding this comment.
I need it because it’s used in index.ts to extract the package version
| "compilerOptions": { | ||
| "strict": true | ||
| }, |
There was a problem hiding this comment.
| "compilerOptions": { | |
| "strict": true | |
| }, |
It's already in the base file.
If you copied this over from somewhere, can you fix that over there too?
apps/settings-service/tsconfig.json
Outdated
| "compilerOptions": { | ||
| "strict": true | ||
| }, |
There was a problem hiding this comment.
| "compilerOptions": { | |
| "strict": true | |
| }, |
| export const logger = Logger.create("SettingsService") | ||
|
|
||
| const responseLogger = Logger.create("Response", LogLevel.TRACE) | ||
| export const logJSONResponseMiddleware = createMiddleware(async (c, next) => { |
There was a problem hiding this comment.
| export const logger = Logger.create("SettingsService") | |
| const responseLogger = Logger.create("Response", LogLevel.TRACE) | |
| export const logJSONResponseMiddleware = createMiddleware(async (c, next) => { | |
| export const logger = Logger.create("SettingsService") | |
| const responseLogger = Logger.create("Response", LogLevel.TRACE) | |
| export const logJSONResponseMiddleware = createMiddleware(async (c, next) => { |
|
|
||
| export function isUUID(str: string): str is UUID { | ||
| return validate(str) && version(str) === 4 | ||
| } |
There was a problem hiding this comment.
Should move to the common package.
| "devDependencies": { | ||
| "@happy.tech/happybuild": "workspace:0.1.1", | ||
| "typescript": "^5.6.2", | ||
| "hono-openapi": "^0.4.4" |
There was a problem hiding this comment.
Duplicate from dependencies.
apps/settings-service/Makefile
Outdated
| include ../../makefiles/bundling.mk | ||
| include ../../makefiles/help.mk | ||
|
|
||
| start: ## Starts the settings service |
There was a problem hiding this comment.
| start: ## Starts the settings service | |
| dev: ## Starts the settings service |
For consistency with other packages.
| exports: [".", "./migrate"], | ||
| bunConfig: { | ||
| minify: false, | ||
| target: "node", |
There was a problem hiding this comment.
Do we want to run this with bun? Currently the submitter uses bun, and I think the other services use node. Did we have a specific reason to avoid bun at the time? I think maybe Mikro-ORM?
There was a problem hiding this comment.
Yes, the reason was that Bun isn't compatible with Mikro-ORM.
apps/settings-service/.env.example
Outdated
| // The app to which the permission is granted. | ||
| invoker: AppURL | ||
| // This is the EIP-1193 request that this permission is mapped to. | ||
| parentCapability: "eth_accounts" | string // TODO only string or make specific |
There was a problem hiding this comment.
let's just make this string + at the site this was coming from and remove the TODO
| * | ||
| * This type is copied from Viem (eip1193.ts) but we add a user field. | ||
| */ | ||
| export type WalletPermissionTable = { |
There was a problem hiding this comment.
| export type WalletPermissionTable = { | |
| export type WalletPermission = { |
Or possibly WalletPermissionRow if you want to use WalletPermission separately, but actually I don't think rows from the DB will be loaded with this type? So maybe not WalletPermisisonRow.
apps/settings-service/src/dtos.ts
Outdated
There was a problem hiding this comment.
dtos as "data transfer objects"?
There was a problem hiding this comment.
Yes
apps/settings-service/src/env.ts
Outdated
| const parsedEnv = envSchema.safeParse(process.env) | ||
|
|
||
| if (!parsedEnv.success) { | ||
| console.log(parsedEnv.error.issues) | ||
| throw new Error("There is an error with the server environment variables") | ||
| } |
There was a problem hiding this comment.
| const parsedEnv = envSchema.safeParse(process.env) | |
| if (!parsedEnv.success) { | |
| console.log(parsedEnv.error.issues) | |
| throw new Error("There is an error with the server environment variables") | |
| } | |
| const parsedEnv = envSchema.parse(process.env) |
The default exception it throws has a good information on what went wrong during the parse, the extra code just suppressed this here.
apps/settings-service/src/index.ts
Outdated
| serve({ | ||
| port: env.APP_PORT, | ||
| fetch: app.fetch, | ||
| }) |
There was a problem hiding this comment.
Super curious why we don't need this in the bun version. cc @not-reed
I don't think it's just a bun thing?
There was a problem hiding this comment.
Yes, it's weird. If you read the documentation of the node-server (https://github.com/honojs/node-server), they mention that this is needed for compatibility reasons with Node. However, we're running this service with Bun, so I removed it
| return { | ||
| user: permission.user, | ||
| invoker: permission.invoker, | ||
| parentCapability: permission.parentCapability, | ||
| caveats: JSON.stringify(permission.caveats), | ||
| date: permission.date, | ||
| id: permission.id, | ||
| updatedAt: permission.updatedAt, | ||
| createdAt: permission.createdAt, | ||
| deleted: permission.deleted, | ||
| } |
There was a problem hiding this comment.
| return { | |
| user: permission.user, | |
| invoker: permission.invoker, | |
| parentCapability: permission.parentCapability, | |
| caveats: JSON.stringify(permission.caveats), | |
| date: permission.date, | |
| id: permission.id, | |
| updatedAt: permission.updatedAt, | |
| createdAt: permission.createdAt, | |
| deleted: permission.deleted, | |
| } | |
| return { | |
| ...permission, | |
| caveats: JSON.stringify(permission.caveats), | |
| } |
Isn't this approach better? Ditto for below.
| .addColumn("parentCapability", "text") | ||
| .addColumn("caveats", "jsonb") | ||
| .addColumn("date", "integer") | ||
| .addColumn("id", "text", (col) => col.notNull()) |
There was a problem hiding this comment.
We should make this a primaryKey.
And shouldn't a lot (most?) of the other fields be non-null?
There was a problem hiding this comment.
Regarding the ID, yes, we can do it. But for the other fields, if we make them non-null, the problem is that if we later want to make them nullable, SQLite doesn't allow altering the column. That happened to me in the randomness service, so I think we don't gain much by adding the non-null restriction. I think that it's better to enforce it in the service logic, and if we ever want to change the rule, we can do so more easily
apps/iframe/src/state/permissions.ts
Outdated
| }, | ||
| persist: { | ||
| plugin: ObservablePersistLocalStorage, | ||
| name: 'config-legend', |
There was a problem hiding this comment.
Is this the name of the local storage key?
There was a problem hiding this comment.
Yes
| const permissionsMap = useAtomValue(permissionsMapAtom) | ||
| const account = useAccount() | ||
| const appsWithPermissions = () => { | ||
| const permissions = permissionsMapLegend.get() | ||
| return Object.values(permissions).filter((permission) => !isWallet(permission.invoker)).reduce((acc, permission) => { | ||
| const existing = acc.find(([app]) => app === permission.invoker) | ||
| if (existing) { | ||
| existing[1][permission.parentCapability] = permission | ||
| } else { | ||
| acc.push([permission.invoker, { | ||
| [permission.parentCapability]: permission | ||
| }]) | ||
| } | ||
| return acc | ||
| }, [] as [AppURL, AppPermissions][]) | ||
| } | ||
|
|
||
| return entries(permissionsMap[account?.address ?? "0x0"] ?? {}) // | ||
| .filter(([app]) => !isWallet(app)) | ||
| return use$(() => appsWithPermissions()) |
There was a problem hiding this comment.
This confused me a good deal, but I see now it's an unfortunate consequence of the changing shape of permissionsMapLegend vs permissionsMapAtom. See my other comment in state.ts, but I think we might be able to preserve the existing shape?
There was a problem hiding this comment.
Actually, we can change it because the atom isn't being used directly in the iframe. It's consumed through some typed methods where I manipulate the list type to convert it to the expected version so nothing breaks. It's working fine now
apps/iframe/src/state/permissions.ts
Outdated
| return hasPermissions(app, permissionsRequest) | ||
|
|
||
|
|
||
| export const permissionsMapLegend = observable(syncedCrud({ |
There was a problem hiding this comment.
The Legend State docs says that this returns by default returns a Record with keys ... so are we just free to use any key we want?
I see you return WalletPermission[] from list — but the doc says you need to you use as: ... in the object you pass to syncedCrud.
This absolutely needs a type.
There was a problem hiding this comment.
The default value returned is a record where the key is the ID of the object—that’s the type it returns. Returning an array directly isn’t an option when consuming the state in the frontend. As stated in the docs (https://legendapp.com/open-source/state/v3/sync/supabase/#as):
Note that array is not an option because arrays make it hard to efficiently and correctly add, update, and remove elements by ID.
The type is already inferred; the observable has this shape: Observable<Record<string, WalletPermission>>, where the string is the ID
As for your question about why the list method returns an array: it's because the list should provide all the permissions that have changed. Internally, Legend State then stores them as a record
apps/settings-service/.env.example
Outdated
There was a problem hiding this comment.
Btw the name of the directory should be sync-service because it doesn't only deal in settings :)
apps/iframe/src/state/permissions.ts
Outdated
| if (app === getWalletURL()) { | ||
| // Permissions don't exist, create them. | ||
| // The iframe is always granted the `eth_accounts` permission. | ||
| const permissionId = createUUID() |
There was a problem hiding this comment.
Using the UUID as key is rather problematic, because as indicated in its comment on WalletPermission, it's not something we really use or need and so it's just filled by generated BS.
Which means it's not deterministic, which means it presents a problem for sync, as if two clients fall out of sync and have both granted connection permission (eth_accounts) to an app, then they will appear as though they are different permissions because of the different UUID.
The same case can also happen here (and this doesn't need out of sync scenarios): the permissions to the iframe are auto-granted.
We could use (user address + appURL) as key instead. That does however make the value arrays of app permissions (or even a recrod) instead of plain permissions. We could also just use the user address (first level of the map), given values of type Record<AppURL, AppPermissions> aka Record<AppURL, Record<string, WalletPermission>> — is using these kinds of values supported? Does the sync work on them? If so, we should use that (at least, at first). So we can preserve our existing schema and logic.
The real question is: what do update actually look like? if I change a field down the object graph, does it only send that field? But also can we send similarly partial objects from the server? (Well I guess we control the list function, so technically yes we can send these partial updates and apply them ourselves to the existing object in the list function)
There was a problem hiding this comment.
^ Most of the value of the review is in this comment.
There was a problem hiding this comment.
I changed it to use the key: const id = ${user.address}-${app}-${permissionName}, and with this change it's working fine
There was a problem hiding this comment.
Now the data structure we save is a WalletPermissions, and when we use the list method, it returns a WalletPermissions[], which is then converted to a Record<string, WalletPermissions>, where the string is the ID of the WalletPermissions. When we perform an update, we only send the specific WalletPermissions that changed
apps/iframe/src/state/permissions.ts
Outdated
| initial: {}, | ||
| fieldCreatedAt: 'created_at', | ||
| fieldUpdatedAt: 'updatedAt', | ||
| fieldDeleted: 'deleted', |
There was a problem hiding this comment.
If you specify this, it doesn't call delete which can be omitted but update instead.
There was a problem hiding this comment.
In the end, we're not using it because I changed the approach from only fetching the differences to loading the entire state each time, as the previous method was causing syncing issues. After that change, we no longer need soft deletes and can fully delete instead
cae317b to
484364e
Compare
not-reed
left a comment
There was a problem hiding this comment.
didn't run it yet, but looks cool so far!
|
|
||
| import { env } from "../env" | ||
|
|
||
| const dbPath = env.SETTINGS_DB_URL || ":memory:" |
There was a problem hiding this comment.
probably makes sense for the default to be set globally in .env ?
| target: "node", | ||
| external: ["better-sqlite3"], |
There was a problem hiding this comment.
| target: "node", | |
| external: ["better-sqlite3"], | |
| target: "bun", |
if your using bun for the runtime in prod (looks like it will be) 😈
apps/sync-service/src/env.ts
Outdated
| import { z } from "zod" | ||
|
|
||
| const envSchema = z.object({ | ||
| NODE_ENV: z.enum(["development", "production"]), |
There was a problem hiding this comment.
| NODE_ENV: z.enum(["development", "production"]), | |
| NODE_ENV: z.enum(["development", "production"]).default('development'), |
| const envSchema = z.object({ | ||
| NODE_ENV: z.enum(["development", "production"]), | ||
| APP_PORT: z.string().transform((s) => Number(s)), | ||
| SETTINGS_DB_URL: z.string().trim(), |
There was a problem hiding this comment.
| SETTINGS_DB_URL: z.string().trim(), | |
| SETTINGS_DB_URL: z.string().trim().default(':memory:'), |
|
|
||
| const envSchema = z.object({ | ||
| NODE_ENV: z.enum(["development", "production"]), | ||
| APP_PORT: z.string().transform((s) => Number(s)), |
There was a problem hiding this comment.
| APP_PORT: z.string().transform((s) => Number(s)), | |
| APP_PORT: z.coerce.number(), |
| @@ -0,0 +1,23 @@ | |||
| import type { ContentfulStatusCode } from "hono/utils/http-status" | |||
|
|
|||
| export abstract class HappySettingsError extends Error { | |||
There was a problem hiding this comment.
I don't think we have used it anywhere, but hono does have an HttpException class (seems thats basically what this is implementing?) https://hono.dev/docs/api/exception#throw-httpexception
|
|
||
| export const deleteConfigSchema = z | ||
| .object({ | ||
| id: z.string().openapi({ example: "78b7d642-e851-4f0f-9cd6-a47c6c2a572a" }), |
There was a problem hiding this comment.
i guess the id is no longer a uuid? (i think i saw that on the front end)
484364e to
8c7a36c
Compare
8c7a36c to
0ff84bd
Compare
HAPPY-284 Backend-driven user information sync
We will want to synchronize the user's information (see list below) between multiple instances of its wallets (tabs, devices, standalone, …). We used to do sync localStorage-driven sync on the same device, but that's doesn't actually work when the wallet iframe is embedded under different domains. It never covered the cross-device use-case which we do want anyway. Reference to the previous conversation on storage-based sync: HAPPY-137 Information to sync (pretty much anything that sits in storage):
What not to sync:
AuthFirst, user info should be private, so there is a need to auth the user. For oauth, this should probably be JWT driven. But how do we make this work for injected wallet controlled account?
Where to sync
How to syncWill depend on the "where", but assuming either a DB or an API endpoint, basically send an atomic "SQL-like" update. There's a question of whether such updates can cover all of our use-cases. Something that (1) reads the storage, then (2) issues an update based on it, could cause race conditions. I believe if the read part of the storage is written as part of the update, then that might alleviate the issue? Not 100% sure. If that doesn't work, then we need API endpoint that perform the read + write transaction atomically on the server. |
95031e8 to
a4907bc
Compare

Linked Issues
Description
This PR is the first version of the sync service, which implements a backend to handle CRUD operations and includes modifications in the iframe to synchronize permissions and watched assets
Toggle Checklist
Checklist
Basics
norswap/build-system-caching).Reminder: PR review guidelines
Correctness
testnet, mainnet, standalone wallet, ...).
< INDICATE BROWSER, DEMO APP & OTHER ENV DETAILS USED FOR TESTING HERE >
< INDICATE TESTED SCENARIOS (USER INTERFACE INTERACTION, CODE FLOWS) HERE >
and have updated the code & comments accordingly.
Architecture & Documentation
(2) commenting these boundaries correctly, (3) adding inline comments for context when needed.
Public APIS and meaningful (non-local) internal APIs are properly documented in code comments.
in a Markdown document.
make changesetforbreaking and meaningful changes in packages (not required for cleanups & refactors).