From f30ed2a8a402abfa11c2a263759bbedb017510a3 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Wed, 11 Mar 2026 19:53:05 -0700 Subject: [PATCH 01/34] [Auth] Add extraFields and created --- client/packages/admin/src/index.ts | 18 +- .../src/auth-extra-fields.e2e.test.ts | 125 +++++++++++ client/packages/core/src/Reactor.js | 43 +++- client/packages/core/src/authAPI.ts | 10 + client/packages/core/src/index.ts | 1 + .../pages/play/oauth-extra-fields.tsx | 208 ++++++++++++++++++ client/www/lib/intern/instant-rules.md | 24 ++ client/www/pages/docs/auth.md | 4 + client/www/pages/docs/backend.md | 19 +- client/www/pages/docs/http-api.md | 6 +- client/www/pages/docs/users.md | 68 ++++++ server/src/instant/admin/routes.clj | 52 +++-- server/src/instant/model/app_user.clj | 28 ++- .../src/instant/runtime/magic_code_auth.clj | 15 +- server/src/instant/runtime/routes.clj | 137 +++++++----- server/test/instant/runtime/routes_test.clj | 170 ++++++++++++++ 16 files changed, 823 insertions(+), 105 deletions(-) create mode 100644 client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts create mode 100644 client/sandbox/react-nextjs/pages/play/oauth-extra-fields.tsx diff --git a/client/packages/admin/src/index.ts b/client/packages/admin/src/index.ts index 89ef9f2db6..0f3bb53ac1 100644 --- a/client/packages/admin/src/index.ts +++ b/client/packages/admin/src/index.ts @@ -515,16 +515,26 @@ class Auth { * * @see https://instantdb.com/docs/backend#custom-magic-codes */ - verifyMagicCode = async (email: string, code: string): Promise => { - const { user } = await jsonFetch( + verifyMagicCode = async ( + email: string, + code: string, + options?: { extraFields?: Record }, + ): Promise => { + const res = await jsonFetch( `${this.config.apiURI}/admin/verify_magic_code?app_id=${this.config.appId}`, { method: 'POST', headers: authorizedHeaders(this.config), - body: JSON.stringify({ email, code }), + body: JSON.stringify({ + email, + code, + ...(options?.extraFields + ? { 'extra-fields': options.extraFields } + : {}), + }), }, ); - return user; + return { ...res.user, created: res.created }; }; /** diff --git a/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts b/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts new file mode 100644 index 0000000000..02238884f9 --- /dev/null +++ b/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts @@ -0,0 +1,125 @@ +import { test as baseTest, expect } from 'vitest'; +import { init, i, type InstantCoreDatabase } from '../../src'; + +// @ts-ignore +const apiUrl = import.meta.env.VITE_INSTANT_DEV + ? 'http://localhost:8888' + : // @ts-ignore + import.meta.env.VITE_INSTANT_API_URL || 'https://api.instantdb.com'; + +// @ts-ignore +const websocketURI = import.meta.env.VITE_INSTANT_DEV + ? 'ws://localhost:8888/runtime/session' + : // @ts-ignore + import.meta.env.VITE_INSTANT_WEBSOCKET_URI || + 'wss://api.instantdb.com/runtime/session'; + +const schema = i.schema({ + entities: { + $users: i.entity({ + email: i.string().unique().indexed().optional(), + username: i.string().unique().indexed().optional(), + displayName: i.string().optional(), + }), + }, +}); + +async function generateMagicCode( + appId: string, + adminToken: string, + email: string, +): Promise { + const res = await fetch(`${apiUrl}/admin/magic_code`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'app-id': appId, + authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ email }), + }); + const data = await res.json(); + return data.code; +} + +const authTest = baseTest.extend<{ + db: InstantCoreDatabase; +}>({ + db: async ({ task, signal }, use) => { + const response = await fetch(`${apiUrl}/dash/apps/ephemeral`, { + body: JSON.stringify({ title: `e2e-auth-${task.id}`, schema }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + signal, + }); + if (!response.ok) { + throw new Error(await response.text()); + } + const { app } = await response.json(); + const db = init({ + appId: app.id, + apiURI: apiUrl, + websocketURI, + schema, + }); + // Stash app info on the db instance for tests to access + (db as any)._testAppId = app.id; + (db as any)._testAdminToken = app.admin_token; + await use(db); + }, +}); + +authTest( + 'new user with extraFields gets fields written and created=true', + async ({ db }) => { + const appId = (db as any)._testAppId; + const adminToken = (db as any)._testAdminToken; + const email = `new-${Date.now()}@test.com`; + + const code = await generateMagicCode(appId, adminToken, email); + const res = await db.auth.signInWithMagicCode({ + email, + code, + extraFields: { username: 'cool_user', displayName: 'Cool User' }, + }); + + expect(res.created).toBe(true); + + const { data } = await db.queryOnce({ $users: {} }); + const user = data.$users.find((u: any) => u.email === email); + expect(user).toBeDefined(); + expect(user!.username).toBe('cool_user'); + expect(user!.displayName).toBe('Cool User'); + }, +); + +authTest('returning user gets created=false', async ({ db }) => { + const appId = (db as any)._testAppId; + const adminToken = (db as any)._testAdminToken; + const email = `returning-${Date.now()}@test.com`; + + // First sign in -- creates user + const code1 = await generateMagicCode(appId, adminToken, email); + const res1 = await db.auth.signInWithMagicCode({ email, code: code1 }); + expect(res1.created).toBe(true); + + // Second sign in -- existing user + const code2 = await generateMagicCode(appId, adminToken, email); + const res2 = await db.auth.signInWithMagicCode({ email, code: code2 }); + expect(res2.created).toBe(false); +}); + +authTest( + 'sign in without extraFields works (backwards compat)', + async ({ db }) => { + const appId = (db as any)._testAppId; + const adminToken = (db as any)._testAdminToken; + const email = `compat-${Date.now()}@test.com`; + + const code = await generateMagicCode(appId, adminToken, email); + const res = await db.auth.signInWithMagicCode({ email, code }); + + expect(res.user).toBeDefined(); + expect(res.user.email).toBe(email); + }, +); diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js index 49a3cbc70f..a36e506b30 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -64,6 +64,8 @@ const defaultConfig = { // Param that the backend adds if this is an oauth redirect const OAUTH_REDIRECT_PARAM = '_instant_oauth_redirect'; +const OAUTH_EXTRA_FIELDS_KEY = '_instant_oauth_extra_fields'; + const currentUserKey = `currentUser`; /** @@ -1951,6 +1953,16 @@ export default class Reactor { } this._replaceUrlAfterOAuth(); try { + let extraFields; + if (typeof sessionStorage !== 'undefined') { + try { + const stored = sessionStorage.getItem(OAUTH_EXTRA_FIELDS_KEY); + if (stored) { + extraFields = JSON.parse(stored); + sessionStorage.removeItem(OAUTH_EXTRA_FIELDS_KEY); + } + } catch (_e) {} + } const currentUser = await this._getCurrentUser(); const isGuest = currentUser?.type === 'guest'; const { user } = await authAPI.exchangeCodeForToken({ @@ -1958,6 +1970,7 @@ export default class Reactor { appId: this.config.appId, code, refreshToken: isGuest ? currentUser.refresh_token : undefined, + extraFields, }); this.setCurrentUser(user); return null; @@ -2199,15 +2212,16 @@ export default class Reactor { }); } - async signInWithMagicCode({ email, code }) { + async signInWithMagicCode(params) { const currentUser = await this.getCurrentUser(); const isGuest = currentUser?.user?.type === 'guest'; const res = await authAPI.verifyMagicCode({ apiURI: this.config.apiURI, appId: this.config.appId, - email, - code, + email: params.email, + code: params.code, refreshToken: isGuest ? currentUser?.user?.refresh_token : undefined, + extraFields: params.extraFields, }); await this.changeCurrentUser(res.user); return res; @@ -2266,9 +2280,16 @@ export default class Reactor { * @param {Object} params - The parameters to create the authorization URL. * @param {string} params.clientName - The name of the client requesting authorization. * @param {string} params.redirectURL - The URL to redirect users to after authorization. + * @param {Record} [params.extraFields] - Extra fields to write to $users on creation * @returns {string} The created authorization URL. */ - createAuthorizationURL({ clientName, redirectURL }) { + createAuthorizationURL({ clientName, redirectURL, extraFields }) { + if (extraFields && typeof sessionStorage !== 'undefined') { + sessionStorage.setItem( + OAUTH_EXTRA_FIELDS_KEY, + JSON.stringify(extraFields), + ); + } const { apiURI, appId } = this.config; return `${apiURI}/runtime/oauth/start?app_id=${appId}&client_name=${clientName}&redirect_uri=${redirectURL}`; } @@ -2277,8 +2298,9 @@ export default class Reactor { * @param {Object} params * @param {string} params.code - The code received from the OAuth service. * @param {string} [params.codeVerifier] - The code verifier used to generate the code challenge. + * @param {Record} [params.extraFields] - Extra fields to write to $users on creation */ - async exchangeCodeForToken({ code, codeVerifier }) { + async exchangeCodeForToken({ code, codeVerifier, extraFields }) { const currentUser = await this.getCurrentUser(); const isGuest = currentUser?.user?.type === 'guest'; const res = await authAPI.exchangeCodeForToken({ @@ -2287,6 +2309,7 @@ export default class Reactor { code: code, codeVerifier, refreshToken: isGuest ? currentUser?.user?.refresh_token : undefined, + extraFields, }); await this.changeCurrentUser(res.user); return res; @@ -2302,18 +2325,20 @@ export default class Reactor { * @param {string} params.clientName - The name of the client requesting authorization. * @param {string} params.idToken - The id_token from the external service * @param {string | null | undefined} [params.nonce] - The nonce used when requesting the id_token from the external service + * @param {Record} [params.extraFields] - Extra fields to write to $users on creation */ - async signInWithIdToken({ idToken, clientName, nonce }) { + async signInWithIdToken(params) { const currentUser = await this.getCurrentUser(); const refreshToken = currentUser?.user?.refresh_token; const res = await authAPI.signInWithIdToken({ apiURI: this.config.apiURI, appId: this.config.appId, - idToken, - clientName, - nonce, + idToken: params.idToken, + clientName: params.clientName, + nonce: params.nonce, refreshToken, + extraFields: params.extraFields, }); await this.changeCurrentUser(res.user); return res; diff --git a/client/packages/core/src/authAPI.ts b/client/packages/core/src/authAPI.ts index 17300e3fe3..db0d35c055 100644 --- a/client/packages/core/src/authAPI.ts +++ b/client/packages/core/src/authAPI.ts @@ -27,9 +27,11 @@ export type VerifyMagicCodeParams = { email: string; code: string; refreshToken?: string | undefined; + extraFields?: Record | undefined; }; export type VerifyResponse = { user: User; + created?: boolean; }; export async function verifyMagicCode({ apiURI, @@ -37,6 +39,7 @@ export async function verifyMagicCode({ email, code, refreshToken, + extraFields, }: SharedInput & VerifyMagicCodeParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/auth/verify_magic_code`, { method: 'POST', @@ -46,6 +49,7 @@ export async function verifyMagicCode({ email, code, ...(refreshToken ? { 'refresh-token': refreshToken } : {}), + ...(extraFields ? { 'extra-fields': extraFields } : {}), }), }); return res; @@ -86,6 +90,7 @@ export type ExchangeCodeForTokenParams = { code: string; codeVerifier?: string; refreshToken?: string | undefined; + extraFields?: Record | undefined; }; export async function exchangeCodeForToken({ @@ -94,6 +99,7 @@ export async function exchangeCodeForToken({ code, codeVerifier, refreshToken, + extraFields, }: SharedInput & ExchangeCodeForTokenParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/oauth/token`, { method: 'POST', @@ -103,6 +109,7 @@ export async function exchangeCodeForToken({ code: code, code_verifier: codeVerifier, refresh_token: refreshToken, + ...(extraFields ? { extra_fields: extraFields } : {}), }), }); return res; @@ -113,6 +120,7 @@ export type SignInWithIdTokenParams = { idToken: string; clientName: string; refreshToken?: string; + extraFields?: Record | undefined; }; export async function signInWithIdToken({ @@ -122,6 +130,7 @@ export async function signInWithIdToken({ idToken, clientName, refreshToken, + extraFields, }: SharedInput & SignInWithIdTokenParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/oauth/id_token`, { method: 'POST', @@ -132,6 +141,7 @@ export async function signInWithIdToken({ id_token: idToken, client_name: clientName, refresh_token: refreshToken, + ...(extraFields ? { extra_fields: extraFields } : {}), }), }); return res; diff --git a/client/packages/core/src/index.ts b/client/packages/core/src/index.ts index dc7bc53a4d..e992e60efa 100644 --- a/client/packages/core/src/index.ts +++ b/client/packages/core/src/index.ts @@ -397,6 +397,7 @@ class Auth { createAuthorizationURL = (params: { clientName: string; redirectURL: string; + extraFields?: Record; }): string => { return this.db.createAuthorizationURL(params); }; diff --git a/client/sandbox/react-nextjs/pages/play/oauth-extra-fields.tsx b/client/sandbox/react-nextjs/pages/play/oauth-extra-fields.tsx new file mode 100644 index 0000000000..ee514a486c --- /dev/null +++ b/client/sandbox/react-nextjs/pages/play/oauth-extra-fields.tsx @@ -0,0 +1,208 @@ +import { i, User, InstantReactAbstractDatabase } from '@instantdb/react'; +import { useState } from 'react'; +import EphemeralAppPage from '../../components/EphemeralAppPage'; +import config from '../../config'; +import Link from 'next/link'; + +const schema = i.schema({ + entities: { + $users: i.entity({ + email: i.string().unique().indexed().optional(), + username: i.string().unique().indexed().optional(), + displayName: i.string().optional(), + }), + }, +}); + +function getAdminToken(appId: string): string | null { + try { + return localStorage.getItem(`ephemeral-admin-token-${appId}`); + } catch { + return null; + } +} + +function App({ + db, + appId, +}: { + db: InstantReactAbstractDatabase; + appId: string; +}) { + const { isLoading, user, error } = db.useAuth(); + if (isLoading) { + return
Loading...
; + } + if (error) { + return ( +
+
Uh oh! {error.message}
+ +
+ ); + } + if (user) { + return
; + } + return ; +} + +function Login({ + db, + appId, +}: { + db: InstantReactAbstractDatabase; + appId: string; +}) { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [sentTo, setSentTo] = useState(null); + const [error, setError] = useState(null); + + const adminToken = getAdminToken(appId); + + return ( +
+ {'<-'} Home +

Extra Fields Test

+
+ + setUsername(e.target.value)} + placeholder="Enter a username" + className="mt-1 rounded border px-3 py-2" + /> +
+ {!adminToken && ( +

+ No admin token found. Try resetting the app. +

+ )} + {!sentTo ? ( +
{ + e.preventDefault(); + try { + if (adminToken) { + // Use admin endpoint to get the code directly + const res = await fetch(`${config.apiURI}/admin/magic_code`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'app-id': appId, + authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ email }), + }); + const data = await res.json(); + setSentTo(email); + setCode(data.code); + } else { + await db.auth.sendMagicCode({ email }); + setSentTo(email); + } + } catch (err: any) { + setError(err.body?.message || err.message); + } + }} + > + setEmail(e.target.value)} + placeholder="Email" + className="mt-1 rounded border px-3 py-2" + /> + +
+ ) : ( +
{ + e.preventDefault(); + try { + const res = await db.auth.signInWithMagicCode({ + email: sentTo, + code, + extraFields: username ? { username } : undefined, + }); + console.log('signInWithMagicCode result:', res); + console.log('created:', res.created); + } catch (err: any) { + setError(err.body?.message || err.message); + } + }} + > +

Code sent to {sentTo}

+ setCode(e.target.value)} + placeholder="Enter code" + className="mt-1 rounded border px-3 py-2" + /> + +
+ )} + {error &&

{error}

} +
+ ); +} + +function Main({ + db, + user, +}: { + db: InstantReactAbstractDatabase; + user: User; +}) { + const { data } = db.useQuery({ $users: {} }); + const currentUser = data?.$users?.find((u: any) => u.id === user.id); + + return ( +
+ {'<-'} Home +

Signed in!

+
+

+ Email: {user.email} +

+

+ ID: {user.id} +

+ {currentUser && ( +
+

+ $users record: +

+
+              {JSON.stringify(currentUser, null, 2)}
+            
+
+ )} +
+ +
+ ); +} + +export default function Page() { + return ; +} diff --git a/client/www/lib/intern/instant-rules.md b/client/www/lib/intern/instant-rules.md index e52210fed0..770fab6b76 100644 --- a/client/www/lib/intern/instant-rules.md +++ b/client/www/lib/intern/instant-rules.md @@ -277,6 +277,30 @@ function App() { } ``` +## Set custom properties at signup with `extraFields` + +Pass `extraFields` to any sign-in method to write custom `$users` properties atomically on user creation. +Fields must be defined as optional attrs on `$users` in your schema. +Use the `created` boolean to scaffold data for new users. + +```tsx +// Set properties at signup +const { user, created } = await db.auth.signInWithMagicCode({ + email, + code, + extraFields: { nickname, createdAt: Date.now() }, +}); + +// Scaffold data for new users +if (created) { + db.transact([ + tx.settings[id()] + .update({ theme: 'light', notifications: true }) + .link({ user: user.id }), + ]); +} +``` + # Ad-hoc queries from the CLI Run `npx instant-cli query '{ posts: {} }' --admin` to query your app. A context flag is required: `--admin`, `--as-email `, or `--as-guest`. Also supports `--app `. diff --git a/client/www/pages/docs/auth.md b/client/www/pages/docs/auth.md index a53cc8b17b..f7f734b674 100644 --- a/client/www/pages/docs/auth.md +++ b/client/www/pages/docs/auth.md @@ -178,3 +178,7 @@ to changes, you can use `getAuth`. const user = await db.getAuth(); console.log('logged in as', user.email); ``` + +### Setting custom properties at signup + +If you want to set custom properties on `$users` at signup time, see [Setting properties at signup](/docs/users#setting-properties-at-signup). diff --git a/client/www/pages/docs/backend.md b/client/www/pages/docs/backend.md index 64fbe644a6..bc81149471 100644 --- a/client/www/pages/docs/backend.md +++ b/client/www/pages/docs/backend.md @@ -397,13 +397,28 @@ You can also use Instant's default email provider to send a magic code with `db. const { code } = await db.auth.sendMagicCode(req.body.email); ``` -Similarly, you can verify a magic code with `db.auth.verifyMagicCode`: +Similarly, you can verify a magic code with `db.auth.verifyMagicCode`. You can pass `extraFields` to set custom `$users` properties when the user is first created. The response includes a `created` boolean. ```typescript {% showCopy=true %} -const user = await db.auth.verifyMagicCode(req.body.email, req.body.code); +const { user, created } = await db.auth.verifyMagicCode( + req.body.email, + req.body.code, + { extraFields: { nickname: req.body.nickname } }, +); const token = user.refresh_token; ``` +You can check whether a new user was created via `user.created`: + +```typescript {% showCopy=true %} +const user = await db.auth.verifyMagicCode(req.body.email, req.body.code, { + extraFields: { nickname: req.body.nickname }, +}); +if (user.created) { + // first-time signup +} +``` + ## Authenticated Endpoints You can also use the admin SDK to authenticate users in your custom endpoints. This would have two steps: diff --git a/client/www/pages/docs/http-api.md b/client/www/pages/docs/http-api.md index d5fe9b57a1..334458875d 100644 --- a/client/www/pages/docs/http-api.md +++ b/client/www/pages/docs/http-api.md @@ -268,7 +268,7 @@ curl -X POST "https://api.instantdb.com/admin/refresh_tokens" \ ``` If a user with the provider id or email does not exist, Instant will create the -user for you. The response includes `user.refresh_token`. You can pass this token onto your client, and use that to [log in](/docs/backend#2-frontend-db-auth-sign-in-with-token) +user for you. You can pass `extra-fields` to set custom `$users` properties on creation. The response includes `user.refresh_token` and `"created": true` when a new user is created. You can pass this token onto your client, and use that to [log in](/docs/backend#2-frontend-db-auth-sign-in-with-token) ## Custom magic codes @@ -292,14 +292,14 @@ curl -X POST "https://api.instantdb.com/admin/send_magic_code" \ -d '{"email":"alyssa_p_hacker@instantdb.com"}' ``` -Similarly, you can verify a magic code too: +Similarly, you can verify a magic code too. Like `refresh_tokens`, you can pass `extra-fields` to set custom `$users` properties on creation. ```shell curl -X POST "https://api.instantdb.com/admin/verify_magic_code" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "App-Id: $APP_ID" \ - -d '{"email":"alyssa_p_hacker@instantdb.com","code":"123456"}' + -d '{"email":"alyssa_p_hacker@instantdb.com","code":"123456","extra-fields":{"nickname":"alyssa"}}' ``` ## Authenticated Endpoints diff --git a/client/www/pages/docs/users.md b/client/www/pages/docs/users.md index 096353e2aa..7b171607bb 100644 --- a/client/www/pages/docs/users.md +++ b/client/www/pages/docs/users.md @@ -176,6 +176,74 @@ const updateNick = (newNick, currentUser) => { At the moment you can only use `transact` to update the custom properties you added. Changing default columns like `email` would cause the transaction to fail. +## Setting properties at signup + +You can set custom `$users` properties at the moment a user is created by passing `extraFields` to your sign-in method. Fields are only written when the user is first created. Returning users are unaffected. + +The fields you pass must be defined in your schema as optional attributes on `$users`. + +**Magic codes** + +```javascript +db.auth.signInWithMagicCode({ + email: sentEmail, + code, + extraFields: { nickname: 'ari_the_great' }, +}); +``` + +**OAuth with ID token** (Google Button, Apple, Clerk, Firebase) + +```javascript +db.auth.signInWithIdToken({ + clientName: 'google', + idToken, + nonce, + extraFields: { nickname: 'ari_the_great' }, +}); +``` + +**OAuth with web redirect** (Google, GitHub, LinkedIn) + +For the redirect flow, pass `extraFields` when creating the authorization URL. Instant stores them and applies them when the user is created after the redirect. + +```javascript +const url = db.auth.createAuthorizationURL({ + clientName: 'google', + redirectURL: window.location.href, + extraFields: { nickname: 'ari_the_great' }, +}); +``` + +**OAuth with code exchange** (Expo, React Native) + +```javascript +db.auth.exchangeOAuthCode({ + code: res.params.code, + codeVerifier: request.codeVerifier, + extraFields: { nickname: 'ari_the_great' }, +}); +``` + +All sign-in methods return a `created` boolean so you can distinguish new users from returning ones. This is useful for scaffolding initial data when a user first signs up: + +```javascript +const { user, created } = await db.auth.signInWithMagicCode({ + email, + code, + extraFields: { nickname }, +}); + +if (created) { + // Create default data for the new user + db.transact([ + tx.settings[id()] + .update({ theme: 'light', notifications: true }) + .link({ user: user.id }), + ]); +} +``` + ## User permissions You can reference the `$users` namespace in your permission rules just like a diff --git a/server/src/instant/admin/routes.clj b/server/src/instant/admin/routes.clj index 813d9fedb5..67b8536483 100644 --- a/server/src/instant/admin/routes.clj +++ b/server/src/instant/admin/routes.clj @@ -397,32 +397,41 @@ (let [{:keys [app-id]} (req->app-id-authed! req :data/write) email (ex/get-optional-param! req [:body :email] email/coerce) id (ex/get-optional-param! req [:body :id] uuid-util/coerce) + extra-fields (get-in req [:body :extra-fields]) + _ (app-user-model/validate-extra-fields! app-id extra-fields) - {user-id :id :as user} + existing-user (cond - email (or (app-user-model/get-by-email {:app-id app-id - :email email}) - - (app-user-model/create! - {:id (UUID/randomUUID) - :app-id app-id - :email email})) - id (or (app-user-model/get-by-id {:app-id app-id :id id}) - (app-user-model/create! - {:id id - :app-id app-id})) + email (app-user-model/get-by-email {:app-id app-id :email email}) + id (app-user-model/get-by-id {:app-id app-id :id id}) :else (ex/throw-validation-err! :body (:body req) [{:message "Please provide an `email` or `id`"}])) + created? (nil? existing-user) + + {user-id :id :as user} + (or existing-user + (cond + email (app-user-model/create! + {:id (UUID/randomUUID) + :app-id app-id + :email email + :extra-fields extra-fields}) + id (app-user-model/create! + {:id id + :app-id app-id + :extra-fields extra-fields}))) + {refresh-token-id :id} (app-user-refresh-token-model/create! {:app-id app-id :id (UUID/randomUUID) :user-id user-id})] - (response/ok {:user (assoc user :refresh_token refresh-token-id)}))) + (response/ok {:user (assoc user :refresh_token refresh-token-id) + :created created?}))) (defn sign-out-post [{:keys [body] :as req}] (let [{:keys [app-id]} (req->app-id-authed! req :data/write) @@ -503,18 +512,23 @@ (let [{:keys [app-id]} (req->app-id-authed! req :data/write) email (ex/get-param! req [:body :email] email/coerce) code (ex/get-param! req [:body :code] string-util/safe-trim) + extra-fields (get-in req [:body :extra-fields]) + _ (app-user-model/validate-extra-fields! app-id extra-fields) guest-user (when-some [refresh-token (ex/get-optional-param! req [:body :refresh-token] uuid-util/coerce)] (let [user (app-user-model/get-by-refresh-token! {:app-id app-id :refresh-token refresh-token})] (when (= "guest" (:type user)) - user)))] + user))) + result (magic-code-auth/verify! + {:app-id app-id + :email email + :code code + :guest-user-id (:id guest-user) + :extra-fields extra-fields})] (response/ok - {:user (magic-code-auth/verify! - {:app-id app-id - :email email - :code code - :guest-user-id (:id guest-user)})}))) + {:user (dissoc result :created) + :created (:created result)}))) (defn sign-in-guest-post [req] (let [{:keys [app-id]} (req->app-id-authed! req :data/write) diff --git a/server/src/instant/model/app_user.clj b/server/src/instant/model/app_user.clj index d617a24ef1..677250c0be 100644 --- a/server/src/instant/model/app_user.clj +++ b/server/src/instant/model/app_user.clj @@ -1,5 +1,6 @@ (ns instant.model.app-user (:require + [instant.db.model.attr :as attr-model] [instant.jdbc.aurora :as aurora] [instant.model.app :as app-model] [instant.model.instant-user :as instant-user-model] @@ -11,10 +12,30 @@ (def etype "$users") +(defn validate-extra-fields! + "Validates that extra-fields keys exist in the $users schema and + are not system fields." + [app-id extra-fields] + (when (seq extra-fields) + (let [attrs (attr-model/get-by-app-id app-id)] + (doseq [[k _v] extra-fields] + (let [k-str (name k) + attr (attr-model/seek-by-fwd-ident-name [etype k-str] attrs)] + (when-not attr + (ex/throw-validation-err! + :extra-fields + extra-fields + [{:message (format "Unknown field: %s. It must be defined in your $users schema." k-str)}])) + (when (= :system (:catalog attr)) + (ex/throw-validation-err! + :extra-fields + extra-fields + [{:message (format "Cannot set system field: %s" k-str)}]))))))) + (defn create! ([params] (create! (aurora/conn-pool :write) params)) - ([conn {:keys [app-id email id type imageURL] :as params}] + ([conn {:keys [app-id email id type imageURL extra-fields] :as params}] (let [id (or id (random-uuid))] (update-op conn @@ -29,7 +50,10 @@ (when (contains? params :type) [[:add-triple id (resolve-id :type) type]]) (when (and (contains? params :imageURL) imageURL) - [[:add-triple id (resolve-id :imageURL) imageURL]]))) + [[:add-triple id (resolve-id :imageURL) imageURL]]) + (map (fn [[k v]] + [:add-triple id (resolve-id (keyword k)) v]) + extra-fields))) (get-entity id)))))) (defn get-by-id diff --git a/server/src/instant/runtime/magic_code_auth.clj b/server/src/instant/runtime/magic_code_auth.clj index 4cda33e83e..682bd42e39 100644 --- a/server/src/instant/runtime/magic_code_auth.clj +++ b/server/src/instant/runtime/magic_code_auth.clj @@ -130,19 +130,22 @@ If a guest-user-id is passed in, it will either upgrade the guest user or link it to the existing user for the email." - [{:keys [app-id email code guest-user-id]}] + [{:keys [app-id email code guest-user-id extra-fields]}] (app-user-magic-code-model/consume! {:app-id app-id :code code :email email}) - (let [user (or (app-user-model/get-by-email - {:app-id app-id - :email email}) + (let [existing-user (app-user-model/get-by-email + {:app-id app-id + :email email}) + created? (nil? existing-user) + user (or existing-user (app-user-model/create! {:id (or guest-user-id (random-uuid)) :app-id app-id :email email - :type "user"})) + :type "user" + :extra-fields extra-fields})) refresh-token-id (random-uuid)] (when (and guest-user-id (not= (:id user) @@ -154,7 +157,7 @@ {:app-id app-id :id refresh-token-id :user-id (:id user)}) - (assoc user :refresh_token refresh-token-id))) + (assoc user :refresh_token refresh-token-id :created created?))) (comment (def instant-user (instant-user-model/get-by-email diff --git a/server/src/instant/runtime/routes.clj b/server/src/instant/runtime/routes.clj index a7b6ea068a..bd9faf0085 100644 --- a/server/src/instant/runtime/routes.clj +++ b/server/src/instant/runtime/routes.clj @@ -91,20 +91,24 @@ (send-magic-code-post {:body {:email "stopa@instantdb.com" :app-id (:id app)}})) (defn verify-magic-code-post [req] - (let [email (ex/get-param! req [:body :email] email/coerce) - code (ex/get-param! req [:body :code] string-util/safe-trim) - app-id (ex/get-param! req [:body :app-id] uuid-util/coerce) - guest-user (when-some [refresh-token (ex/get-optional-param! req [:body :refresh-token] uuid-util/coerce)] - (let [user (app-user-model/get-by-refresh-token! - {:app-id app-id - :refresh-token refresh-token})] - (when (= "guest" (:type user)) - user))) - user (magic-code-auth/verify! {:app-id app-id - :email email - :code code - :guest-user-id (:id guest-user)})] - (response/ok {:user user}))) + (let [email (ex/get-param! req [:body :email] email/coerce) + code (ex/get-param! req [:body :code] string-util/safe-trim) + app-id (ex/get-param! req [:body :app-id] uuid-util/coerce) + extra-fields (get-in req [:body :extra-fields]) + _ (app-user-model/validate-extra-fields! app-id extra-fields) + guest-user (when-some [refresh-token (ex/get-optional-param! req [:body :refresh-token] uuid-util/coerce)] + (let [user (app-user-model/get-by-refresh-token! + {:app-id app-id + :refresh-token refresh-token})] + (when (= "guest" (:type user)) + user))) + result (magic-code-auth/verify! {:app-id app-id + :email email + :code code + :guest-user-id (:id guest-user) + :extra-fields extra-fields})] + (response/ok {:user (dissoc result :created) + :created (:created result)}))) (comment (def instant-user (instant-user-model/get-by-email @@ -252,7 +256,7 @@ ;; matches everything under the subdirectory :path "/runtime/oauth"})))) -(defn upsert-oauth-link! [{:keys [email sub imageURL app-id provider-id guest-user-id]}] +(defn upsert-oauth-link! [{:keys [email sub imageURL app-id provider-id guest-user-id extra-fields]}] (let [users (app-user-model/get-by-email-or-oauth-link-qualified {:email email :app-id app-id @@ -280,17 +284,19 @@ :sub (:app_user_oauth_links/sub oauth-link)})})))] (if-not user - (let [created (app-user-model/create! - {:id guest-user-id - :app-id app-id - :email email - :imageURL imageURL - :type "user"})] - (app-user-oauth-link-model/create! {:id (random-uuid) - :app-id app-id - :provider-id provider-id - :sub sub - :user-id (:id created)})) + (let [new-user (app-user-model/create! + {:id guest-user-id + :app-id app-id + :email email + :imageURL imageURL + :type "user" + :extra-fields extra-fields})] + (assoc (app-user-oauth-link-model/create! {:id (random-uuid) + :app-id app-id + :provider-id provider-id + :sub sub + :user-id (:id new-user)}) + :created true)) (do ;; extra caution because it would be really bad to @@ -311,36 +317,38 @@ :id (:app_users/id user) :image-url imageURL}))) - (cond (and email (not= (:app_users/email user) email)) - (tracer/with-span! {:name "app-user/update-email" - :attributes {:id (:app_users/id user) - :from-email (:app_users/email user) - :to-email email}} - (app-user-model/update-email! {:id (:app_users/id user) - :app-id app-id - :email email}) - (ucoll/select-keys-no-ns user :app_user_oauth_links)) - - (and existing-oauth-link (not (:app_user_oauth_links/id user))) - (tracer/with-span! {:name "oauth-link/reassign" - :attributes {:link-id (:id existing-oauth-link) - :to-user-id (:app_users/id user)}} - (app-user-oauth-link-model/update-user! {:id (:id existing-oauth-link) - :app-id app-id - :user-id (:app_users/id user)})) - - (not (:app_user_oauth_links/id user)) - (tracer/with-span! {:name "oauth-link/create" - :attributes {:id (:app_users/id user) - :provider_id provider-id - :sub sub}} - (app-user-oauth-link-model/create! {:id (random-uuid) - :app-id (:app_users/app_id user) - :provider-id provider-id - :sub sub - :user-id (:app_users/id user)})) - - :else (ucoll/select-keys-no-ns user :app_user_oauth_links)))))) + (assoc + (cond (and email (not= (:app_users/email user) email)) + (tracer/with-span! {:name "app-user/update-email" + :attributes {:id (:app_users/id user) + :from-email (:app_users/email user) + :to-email email}} + (app-user-model/update-email! {:id (:app_users/id user) + :app-id app-id + :email email}) + (ucoll/select-keys-no-ns user :app_user_oauth_links)) + + (and existing-oauth-link (not (:app_user_oauth_links/id user))) + (tracer/with-span! {:name "oauth-link/reassign" + :attributes {:link-id (:id existing-oauth-link) + :to-user-id (:app_users/id user)}} + (app-user-oauth-link-model/update-user! {:id (:id existing-oauth-link) + :app-id app-id + :user-id (:app_users/id user)})) + + (not (:app_user_oauth_links/id user)) + (tracer/with-span! {:name "oauth-link/create" + :attributes {:id (:app_users/id user) + :provider_id provider-id + :sub sub}} + (app-user-oauth-link-model/create! {:id (random-uuid) + :app-id (:app_users/app_id user) + :provider-id provider-id + :sub sub + :user-id (:app_users/id user)})) + + :else (ucoll/select-keys-no-ns user :app_user_oauth_links)) + :created false))))) (def oauth-callback-testing-landing @@ -575,6 +583,7 @@ (let [app-id (ex/get-some-param! req (param-paths :app_id) uuid-util/coerce) code (ex/get-some-param! req (param-paths :code) uuid-util/coerce) code-verifier (some #(get-in req %) (param-paths :code_verifier)) + extra-fields (get-in req [:body :extra_fields]) oauth-code (app-oauth-code-model/consume! {:code code :app-id app-id :verifier code-verifier}) @@ -587,6 +596,7 @@ {:keys [app_id client_id user_info]} oauth-code _ (assert (= app-id app_id) (str "(= " app-id " " app_id ")")) + _ (app-user-model/validate-extra-fields! app-id extra-fields) client (or (app-oauth-client-model/get-by-id {:app-id app-id :id client_id}) @@ -599,12 +609,14 @@ (when (= "guest" (:type user)) user))) - {user-id :user_id} (upsert-oauth-link! {:email (get user_info "email") + {user-id :user_id + :as social-login} (upsert-oauth-link! {:email (get user_info "email") :sub (get user_info "sub") :imageURL (get user_info "imageURL") :app-id app-id :provider-id (:provider_id client) - :guest-user-id (:id guest-user)}) + :guest-user-id (:id guest-user) + :extra-fields extra-fields}) refresh-token-id (random-uuid) @@ -620,6 +632,7 @@ (assert (= app-id (:app_id user)) (str "(= " app-id " " (:app_id user) ")")) (response/ok {:user (assoc user :refresh_token refresh-token-id) + :created (:created social-login) :refresh_token refresh-token-id}))) (defn oauth-id-token-callback [{{:keys [nonce]} :body :as req}] @@ -627,6 +640,8 @@ app-id (ex/get-param! req [:body :app_id] uuid-util/coerce) current-refresh-token-id (ex/get-optional-param! req [:body :refresh_token] uuid-util/coerce) client-name (ex/get-param! req [:body :client_name] string-util/coerce-non-blank-str) + extra-fields (get-in req [:body :extra_fields]) + _ (app-user-model/validate-extra-fields! app-id extra-fields) client (app-oauth-client-model/get-by-client-name! {:app-id app-id :client-name client-name}) oauth-client (app-oauth-client-model/->OAuthClient client) @@ -667,7 +682,8 @@ :imageURL imageURL :app-id (:app_id client) :provider-id (:provider_id client) - :guest-user-id (:id guest-user)}) + :guest-user-id (:id guest-user) + :extra-fields extra-fields}) {refresh-token-id :id} (if (and current-refresh-token (= (:user_id social-login) @@ -678,7 +694,8 @@ :user-id (:user_id social-login)})) user (app-user-model/get-by-id {:app-id app-id :id (:user_id social-login)})] (assert (= app-id (:app_id user))) - (response/ok {:user (assoc user :refresh_token refresh-token-id)}))) + (response/ok {:user (assoc user :refresh_token refresh-token-id) + :created (:created social-login)}))) (defn openid-configuration-get [req] (let [app-id (ex/get-param! req [:params :app_id] uuid-util/coerce)] diff --git a/server/test/instant/runtime/routes_test.clj b/server/test/instant/runtime/routes_test.clj index c78a23d386..ab5b558f29 100644 --- a/server/test/instant/runtime/routes_test.clj +++ b/server/test/instant/runtime/routes_test.clj @@ -3,6 +3,7 @@ [clj-http.client :as http] [clojure.test :refer [deftest is testing]] [instant.config :as config] + [instant.db.model.attr :as attr-model] [instant.fixtures :refer [with-empty-app]] [instant.jdbc.aurora :as aurora] [instant.jdbc.sql :as sql] @@ -376,3 +377,172 @@ (is anon-link) (is (not= (:id email-user) (:user_id anon-link))) (is (= (:id email-user) (:user_id revealed-link))))))) + +;; ----- +;; Extra fields on signup + +(defn verify-code-body-runtime [app body] + (-> (request {:method :post + :url "/runtime/auth/verify_magic_code" + :body (assoc body :app-id (:id app))}) + :body)) + +(defn verify-code-body-admin [app body] + (-> (request {:method :post + :url "/admin/verify_magic_code" + :headers {"app-id" (:id app) + "authorization" (str "Bearer " (:admin-token app))} + :body body}) + :body)) + +(deftest extra-fields-magic-code-test + (test-util/test-matrix + [[send-code verify-body] + [[send-code-runtime verify-code-body-runtime] + [post-code-admin verify-code-body-admin]]] + (with-empty-app + (fn [{app-id :id :as app}] + ;; Add custom attrs to $users + (test-util/make-attrs app-id + [[:$users/username :unique? :index?] + [:$users/displayName]]) + + (testing "new user with extra-fields" + (let [code (send-code app {:email "new@test.com"}) + body (verify-body app {:email "new@test.com" + :code code + :extra-fields {"username" "cool_user" + "displayName" "Cool User"}}) + user (app-user-model/get-by-email {:app-id app-id + :email "new@test.com"})] + (is (true? (:created body))) + (is (= "new@test.com" (-> body :user :email))) + (is (= "cool_user" (:username user))) + (is (= "Cool User" (:displayName user))))) + + (testing "existing user ignores extra-fields" + (let [code (send-code app {:email "new@test.com"}) + body (verify-body app {:email "new@test.com" + :code code + :extra-fields {"username" "different_name" + "displayName" "Different"}}) + user (app-user-model/get-by-email {:app-id app-id + :email "new@test.com"})] + (is (false? (:created body))) + (is (= "cool_user" (:username user))) + (is (= "Cool User" (:displayName user))))) + + (testing "without extra-fields (backwards compat)" + (let [code (send-code app {:email "compat@test.com"}) + body (verify-body app {:email "compat@test.com" + :code code})] + (is (true? (:created body))) + (is (= "compat@test.com" (-> body :user :email))))) + + (testing "unknown keys rejected" + (let [code (send-code app {:email "bad@test.com"})] + (is (thrown-with-msg? + ExceptionInfo #"status 400" + (verify-body app {:email "bad@test.com" + :code code + :extra-fields {"nonexistent" "value"}}))))) + + (testing "system fields rejected" + (let [code (send-code app {:email "sys@test.com"})] + (is (thrown-with-msg? + ExceptionInfo #"status 400" + (verify-body app {:email "sys@test.com" + :code code + :extra-fields {"email" "evil@test.com"}}))))))))) + +(deftest extra-fields-guest-upgrade-test + (test-util/test-matrix + [sign-in-guest [sign-in-guest-runtime + sign-in-guest-admin] + send-code [send-code-runtime] + verify-body [verify-code-body-runtime + verify-code-body-admin]] + (with-empty-app + (fn [{app-id :id :as app}] + (test-util/make-attrs app-id + [[:$users/username]]) + + (let [guest (sign-in-guest app) + _ (is (= "guest" (:type guest))) + code (send-code app {:email "guest@test.com"}) + body (verify-body app {:email "guest@test.com" + :code code + :refresh-token (:refresh_token guest) + :extra-fields {"username" "upgraded_user"}}) + user (app-user-model/get-by-email {:app-id app-id + :email "guest@test.com"})] + (is (true? (:created body))) + (is (= (:id guest) (-> body :user :id))) + (is (= "upgraded_user" (:username user)))))))) + +(deftest extra-fields-oauth-test + (with-empty-app + (fn [{app-id :id :as app}] + (test-util/make-attrs app-id + [[:$users/username] + [:$users/displayName]]) + + (let [provider (provider-model/create! {:app-id app-id + :provider-name "clerk"})] + + (testing "new user with extra-fields via oauth" + (let [result (route/upsert-oauth-link! {:email "oauth@test.com" + :sub "oauth-sub-1" + :app-id app-id + :provider-id (:id provider) + :extra-fields {"username" "oauth_user" + "displayName" "OAuth User"}}) + user (app-user-model/get-by-id {:id (:user_id result) + :app-id app-id})] + (is (true? (:created result))) + (is (= "oauth_user" (:username user))) + (is (= "OAuth User" (:displayName user))))) + + (testing "existing oauth user ignores extra-fields" + (let [result (route/upsert-oauth-link! {:email "oauth@test.com" + :sub "oauth-sub-1" + :app-id app-id + :provider-id (:id provider) + :extra-fields {"username" "different_name"}}) + user (app-user-model/get-by-id {:id (:user_id result) + :app-id app-id})] + (is (false? (:created result))) + (is (= "oauth_user" (:username user))))))))) + +(deftest extra-fields-admin-refresh-tokens-test + (with-empty-app + (fn [{app-id :id :as app}] + (test-util/make-attrs app-id + [[:$users/username]]) + + (testing "new user with extra-fields via admin refresh-tokens" + (let [resp (request {:method :post + :url "/admin/refresh_tokens" + :headers {"app-id" app-id + "authorization" (str "Bearer " (:admin-token app))} + :body {:email "admin@test.com" + :extra-fields {"username" "admin_user"}}}) + body (:body resp) + user (app-user-model/get-by-email {:app-id app-id + :email "admin@test.com"})] + (is (true? (:created body))) + (is (= "admin_user" (:username user))))) + + (testing "existing user ignores extra-fields" + (let [resp (request {:method :post + :url "/admin/refresh_tokens" + :headers {"app-id" app-id + "authorization" (str "Bearer " (:admin-token app))} + :body {:email "admin@test.com" + :extra-fields {"username" "different"}}}) + body (:body resp) + user (app-user-model/get-by-email {:app-id app-id + :email "admin@test.com"})] + (is (false? (:created body))) + (is (= "admin_user" (:username user)))))))) + From 96a4ad5f2e2502a62062fafbcea0f5d796bae253 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 12 Mar 2026 03:59:58 -0700 Subject: [PATCH 02/34] Copy rules --- .../template/rules/AGENTS.md | 24 +++++++++++++++++++ .../template/rules/cursor-rules.md | 24 +++++++++++++++++++ .../template/rules/windsurf-rules.md | 24 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/client/packages/create-instant-app/template/rules/AGENTS.md b/client/packages/create-instant-app/template/rules/AGENTS.md index e52210fed0..770fab6b76 100644 --- a/client/packages/create-instant-app/template/rules/AGENTS.md +++ b/client/packages/create-instant-app/template/rules/AGENTS.md @@ -277,6 +277,30 @@ function App() { } ``` +## Set custom properties at signup with `extraFields` + +Pass `extraFields` to any sign-in method to write custom `$users` properties atomically on user creation. +Fields must be defined as optional attrs on `$users` in your schema. +Use the `created` boolean to scaffold data for new users. + +```tsx +// Set properties at signup +const { user, created } = await db.auth.signInWithMagicCode({ + email, + code, + extraFields: { nickname, createdAt: Date.now() }, +}); + +// Scaffold data for new users +if (created) { + db.transact([ + tx.settings[id()] + .update({ theme: 'light', notifications: true }) + .link({ user: user.id }), + ]); +} +``` + # Ad-hoc queries from the CLI Run `npx instant-cli query '{ posts: {} }' --admin` to query your app. A context flag is required: `--admin`, `--as-email `, or `--as-guest`. Also supports `--app `. diff --git a/client/packages/create-instant-app/template/rules/cursor-rules.md b/client/packages/create-instant-app/template/rules/cursor-rules.md index 19fb9aca2d..f1b361cd58 100644 --- a/client/packages/create-instant-app/template/rules/cursor-rules.md +++ b/client/packages/create-instant-app/template/rules/cursor-rules.md @@ -283,6 +283,30 @@ function App() { } ``` +## Set custom properties at signup with `extraFields` + +Pass `extraFields` to any sign-in method to write custom `$users` properties atomically on user creation. +Fields must be defined as optional attrs on `$users` in your schema. +Use the `created` boolean to scaffold data for new users. + +```tsx +// Set properties at signup +const { user, created } = await db.auth.signInWithMagicCode({ + email, + code, + extraFields: { nickname, createdAt: Date.now() }, +}); + +// Scaffold data for new users +if (created) { + db.transact([ + tx.settings[id()] + .update({ theme: 'light', notifications: true }) + .link({ user: user.id }), + ]); +} +``` + # Ad-hoc queries from the CLI Run `npx instant-cli query '{ posts: {} }' --admin` to query your app. A context flag is required: `--admin`, `--as-email `, or `--as-guest`. Also supports `--app `. diff --git a/client/packages/create-instant-app/template/rules/windsurf-rules.md b/client/packages/create-instant-app/template/rules/windsurf-rules.md index 006617d73a..5e049bd17f 100644 --- a/client/packages/create-instant-app/template/rules/windsurf-rules.md +++ b/client/packages/create-instant-app/template/rules/windsurf-rules.md @@ -283,6 +283,30 @@ function App() { } ``` +## Set custom properties at signup with `extraFields` + +Pass `extraFields` to any sign-in method to write custom `$users` properties atomically on user creation. +Fields must be defined as optional attrs on `$users` in your schema. +Use the `created` boolean to scaffold data for new users. + +```tsx +// Set properties at signup +const { user, created } = await db.auth.signInWithMagicCode({ + email, + code, + extraFields: { nickname, createdAt: Date.now() }, +}); + +// Scaffold data for new users +if (created) { + db.transact([ + tx.settings[id()] + .update({ theme: 'light', notifications: true }) + .link({ user: user.id }), + ]); +} +``` + # Ad-hoc queries from the CLI Run `npx instant-cli query '{ posts: {} }' --admin` to query your app. A context flag is required: `--admin`, `--as-email `, or `--as-guest`. Also supports `--app `. From f3e0356d22c093ac9282625c5cbbf1b89a71f41d Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 12 Mar 2026 04:46:11 -0700 Subject: [PATCH 03/34] Add Google OAuth redirect and natie test --- .../pages/play/oauth-extra-fields-google.tsx | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx diff --git a/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx b/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx new file mode 100644 index 0000000000..78e6f56745 --- /dev/null +++ b/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx @@ -0,0 +1,137 @@ +import { init, User, i } from '@instantdb/react'; +import { useState } from 'react'; +import { GoogleOAuthProvider, GoogleLogin } from '@react-oauth/google'; +import config from '../../config'; +import Link from 'next/link'; + +const APP_ID = '2d960014-0690-4dc5-b13f-a3c202663241'; +const GOOGLE_CLIENT_ID = + '873926401300-t33oit5b8j5n0gl1nkk9fee6lvuiaia0.apps.googleusercontent.com'; + +const schema = i.schema({ + entities: { + $users: i.entity({ + email: i.string().unique().indexed().optional(), + displayName: i.string().optional(), + }), + }, +}); + +const db = init({ + ...config, + appId: APP_ID, + schema, +}); + +function App() { + const { isLoading, user, error } = db.useAuth(); + if (isLoading) { + return
Loading...
; + } + if (error) { + return ( +
+
Uh oh! {error.message}
+ +
+ ); + } + if (user) { + return
; + } + return ; +} + +function Login() { + const [displayName, setDisplayName] = useState(''); + const [nonce] = useState(crypto.randomUUID()); + + const redirectLoginURL = db.auth.createAuthorizationURL({ + clientName: 'google-web', + redirectURL: window.location.href, + extraFields: displayName ? { displayName } : undefined, + }); + + return ( + +
+ {'<-'} Home +

+ Google OAuth Extra Fields Test +

+
+ + setDisplayName(e.target.value)} + placeholder="Enter a display name" + className="mt-1 rounded border px-3 py-2" + /> +
+
+ + Google (Redirect) + + alert('Login failed')} + onSuccess={({ credential }) => { + db.auth + .signInWithIdToken({ + clientName: 'google-button-for-web', + idToken: credential, + nonce, + extraFields: displayName ? { displayName } : undefined, + }) + .catch((err) => { + alert('Uh oh: ' + err.body?.message); + }); + }} + /> +
+
+
+ ); +} + +function Main({ user }: { user: User }) { + const { data } = db.useQuery({ $users: {} }); + const currentUser = data?.$users?.find((u: any) => u.id === user.id); + + return ( +
+ {'<-'} Home +

Signed in!

+
+

+ Email: {user.email} +

+

+ ID: {user.id} +

+ {currentUser && ( +
+

+ $users record: +

+
+              {JSON.stringify(currentUser, null, 2)}
+            
+
+ )} +
+ +
+ ); +} + +export default App; From 75051af92461e1e6b9f88ca964b066d0423fdbb1 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 12 Mar 2026 05:09:08 -0700 Subject: [PATCH 04/34] sm --- .../pages/play/oauth-extra-fields-google.tsx | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx b/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx index 78e6f56745..eba81ac08b 100644 --- a/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx +++ b/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx @@ -4,7 +4,7 @@ import { GoogleOAuthProvider, GoogleLogin } from '@react-oauth/google'; import config from '../../config'; import Link from 'next/link'; -const APP_ID = '2d960014-0690-4dc5-b13f-a3c202663241'; +const APP_ID = process.env.NEXT_PUBLIC_INSTANT_APP_ID; const GOOGLE_CLIENT_ID = '873926401300-t33oit5b8j5n0gl1nkk9fee6lvuiaia0.apps.googleusercontent.com'; @@ -19,7 +19,7 @@ const schema = i.schema({ const db = init({ ...config, - appId: APP_ID, + appId: APP_ID!, schema, }); @@ -39,7 +39,59 @@ function App() { if (user) { return
; } - return ; + return ( +
+ + +
+ ); +} + +function Instructions() { + return ( +
+

Prerequisites

+
    +
  1. + Sandbox app in `.env` must exist with{' '} + displayName as an + optional attr on{' '} + $users +
  2. +
  3. + Set up Google OAuth clients via dashboard. Use{' '} + google-web for + redirect and{' '} + + google-button-for-web + {' '} + for the native button +
  4. +
  5. Local server running with the extra-fields branch
  6. +
  7. + (Optional) For Google Button: sync your clock to avoid clock sync + error:{' '} + + sudo sntp -sS time.apple.com + +
  8. +
+

Testing

+
    +
  1. Type a display name below
  2. +
  3. Click either "Google (Redirect)" or the Google Button
  4. +
  5. After sign-in, the $users record should show your displayName
  6. +
  7. + Sign out, delete the user from the explorer, and sign in again without + a display name to verify backwards compat +
  8. +
+

+ extraFields are only written on first creation. If displayName is + missing, the user likely already existed. Delete and retry. +

+
+ ); } function Login() { From 258659a00bb98cf87e7c211ab9f7d5d9bb2a3901 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 12 Mar 2026 05:09:13 -0700 Subject: [PATCH 05/34] sm --- client/www/pages/docs/users.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/www/pages/docs/users.md b/client/www/pages/docs/users.md index 7b171607bb..4a9732f5ee 100644 --- a/client/www/pages/docs/users.md +++ b/client/www/pages/docs/users.md @@ -188,7 +188,7 @@ The fields you pass must be defined in your schema as optional attributes on `$u db.auth.signInWithMagicCode({ email: sentEmail, code, - extraFields: { nickname: 'ari_the_great' }, + extraFields: { nickname: 'nezaj' }, }); ``` @@ -199,7 +199,7 @@ db.auth.signInWithIdToken({ clientName: 'google', idToken, nonce, - extraFields: { nickname: 'ari_the_great' }, + extraFields: { nickname: 'nezaj' }, }); ``` @@ -211,7 +211,7 @@ For the redirect flow, pass `extraFields` when creating the authorization URL. I const url = db.auth.createAuthorizationURL({ clientName: 'google', redirectURL: window.location.href, - extraFields: { nickname: 'ari_the_great' }, + extraFields: { nickname: 'nezaj' }, }); ``` @@ -221,7 +221,7 @@ const url = db.auth.createAuthorizationURL({ db.auth.exchangeOAuthCode({ code: res.params.code, codeVerifier: request.codeVerifier, - extraFields: { nickname: 'ari_the_great' }, + extraFields: { nickname: 'nezaj' }, }); ``` From 10660181170efa9eaab0df3ef3d5edd906721b8c Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 12 Mar 2026 09:29:05 -0700 Subject: [PATCH 06/34] Better tests --- .../src/auth-extra-fields.e2e.test.ts | 80 +++++-------------- .../packages/core/__tests__/src/utils/e2e.ts | 11 +++ 2 files changed, 31 insertions(+), 60 deletions(-) diff --git a/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts b/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts index 02238884f9..aa4b1e7440 100644 --- a/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts +++ b/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts @@ -1,18 +1,6 @@ -import { test as baseTest, expect } from 'vitest'; -import { init, i, type InstantCoreDatabase } from '../../src'; - -// @ts-ignore -const apiUrl = import.meta.env.VITE_INSTANT_DEV - ? 'http://localhost:8888' - : // @ts-ignore - import.meta.env.VITE_INSTANT_API_URL || 'https://api.instantdb.com'; - -// @ts-ignore -const websocketURI = import.meta.env.VITE_INSTANT_DEV - ? 'ws://localhost:8888/runtime/session' - : // @ts-ignore - import.meta.env.VITE_INSTANT_WEBSOCKET_URI || - 'wss://api.instantdb.com/runtime/session'; +import { expect } from 'vitest'; +import { i } from '../../src'; +import { makeE2ETest, apiUrl } from './utils/e2e'; const schema = i.schema({ entities: { @@ -42,38 +30,11 @@ async function generateMagicCode( return data.code; } -const authTest = baseTest.extend<{ - db: InstantCoreDatabase; -}>({ - db: async ({ task, signal }, use) => { - const response = await fetch(`${apiUrl}/dash/apps/ephemeral`, { - body: JSON.stringify({ title: `e2e-auth-${task.id}`, schema }), - headers: { 'Content-Type': 'application/json' }, - method: 'POST', - signal, - }); - if (!response.ok) { - throw new Error(await response.text()); - } - const { app } = await response.json(); - const db = init({ - appId: app.id, - apiURI: apiUrl, - websocketURI, - schema, - }); - // Stash app info on the db instance for tests to access - (db as any)._testAppId = app.id; - (db as any)._testAdminToken = app.admin_token; - await use(db); - }, -}); +const authTest = makeE2ETest({ schema }); authTest( 'new user with extraFields gets fields written and created=true', - async ({ db }) => { - const appId = (db as any)._testAppId; - const adminToken = (db as any)._testAdminToken; + async ({ db, appId, adminToken }) => { const email = `new-${Date.now()}@test.com`; const code = await generateMagicCode(appId, adminToken, email); @@ -93,27 +54,26 @@ authTest( }, ); -authTest('returning user gets created=false', async ({ db }) => { - const appId = (db as any)._testAppId; - const adminToken = (db as any)._testAdminToken; - const email = `returning-${Date.now()}@test.com`; +authTest( + 'returning user gets created=false', + async ({ db, appId, adminToken }) => { + const email = `returning-${Date.now()}@test.com`; - // First sign in -- creates user - const code1 = await generateMagicCode(appId, adminToken, email); - const res1 = await db.auth.signInWithMagicCode({ email, code: code1 }); - expect(res1.created).toBe(true); + // First sign in -- creates user + const code1 = await generateMagicCode(appId, adminToken, email); + const res1 = await db.auth.signInWithMagicCode({ email, code: code1 }); + expect(res1.created).toBe(true); - // Second sign in -- existing user - const code2 = await generateMagicCode(appId, adminToken, email); - const res2 = await db.auth.signInWithMagicCode({ email, code: code2 }); - expect(res2.created).toBe(false); -}); + // Second sign in -- existing user + const code2 = await generateMagicCode(appId, adminToken, email); + const res2 = await db.auth.signInWithMagicCode({ email, code: code2 }); + expect(res2.created).toBe(false); + }, +); authTest( 'sign in without extraFields works (backwards compat)', - async ({ db }) => { - const appId = (db as any)._testAppId; - const adminToken = (db as any)._testAdminToken; + async ({ db, appId, adminToken }) => { const email = `compat-${Date.now()}@test.com`; const code = await generateMagicCode(appId, adminToken, email); diff --git a/client/packages/core/__tests__/src/utils/e2e.ts b/client/packages/core/__tests__/src/utils/e2e.ts index 668a1c173d..0d183f0fb1 100644 --- a/client/packages/core/__tests__/src/utils/e2e.ts +++ b/client/packages/core/__tests__/src/utils/e2e.ts @@ -31,6 +31,8 @@ export function makeE2ETest>({ }) { return baseTest.extend<{ db: InstantCoreDatabase; + appId: string; + adminToken: string; }>({ db: async ({ task, signal }, use) => { const response = await fetch(`${apiUrl}/dash/apps/ephemeral`, { @@ -49,9 +51,18 @@ export function makeE2ETest>({ websocketURI, schema, }); + (db as any)._testApp = app; await use(db); }, + appId: async ({ db }, use) => { + await use((db as any)._testApp.id); + }, + adminToken: async ({ db }, use) => { + await use((db as any)._testApp.admin_token); + }, }); } +export { apiUrl }; + export const e2eTest = makeE2ETest({}); From fb1f13f0f2d58cd209853d59372e56429e33b67f Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 12 Mar 2026 09:30:56 -0700 Subject: [PATCH 07/34] sm --- .../react-nextjs/pages/play/oauth-extra-fields-google.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx b/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx index eba81ac08b..488e6739ed 100644 --- a/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx +++ b/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx @@ -132,6 +132,7 @@ function Login() { nonce={nonce} onError={() => alert('Login failed')} onSuccess={({ credential }) => { + if (!credential) return; db.auth .signInWithIdToken({ clientName: 'google-button-for-web', From 671e4bcb021b94c5833e8d79cb3da40de6aa89f1 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 12 Mar 2026 10:25:18 -0700 Subject: [PATCH 08/34] sm --- client/packages/core/__tests__/src/utils/e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/packages/core/__tests__/src/utils/e2e.ts b/client/packages/core/__tests__/src/utils/e2e.ts index 0d183f0fb1..b67a59cd80 100644 --- a/client/packages/core/__tests__/src/utils/e2e.ts +++ b/client/packages/core/__tests__/src/utils/e2e.ts @@ -58,7 +58,7 @@ export function makeE2ETest>({ await use((db as any)._testApp.id); }, adminToken: async ({ db }, use) => { - await use((db as any)._testApp.admin_token); + await use((db as any)._testApp['admin-token']); }, }); } From 9feb723e1327928024617b6001ca42fab5adc17e Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 12 Mar 2026 10:29:23 -0700 Subject: [PATCH 09/34] Enable e2e tests against multiple checkouts --- .../packages/core/__tests__/src/utils/e2e.ts | 23 ++++++++++--------- client/packages/core/package.json | 1 + client/packages/core/vitest.config.ts | 6 +++++ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/client/packages/core/__tests__/src/utils/e2e.ts b/client/packages/core/__tests__/src/utils/e2e.ts index b67a59cd80..323d216a3a 100644 --- a/client/packages/core/__tests__/src/utils/e2e.ts +++ b/client/packages/core/__tests__/src/utils/e2e.ts @@ -6,18 +6,19 @@ import { InstantSchemaDef, } from '../../../src'; -// @ts-ignore -const apiUrl = import.meta.env.VITE_INSTANT_DEV - ? 'http://localhost:8888' - : // @ts-ignore - import.meta.env.VITE_INSTANT_API_URL || 'https://api.instantdb.com'; +// __DEV_LOCAL_PORT__ is set by vitest.config.ts. +// This allows us to run tests against mulutple checkouts +// If CI=1 then __DEV_LOCAL_PORT__ will be falsey and tests will hit prod. +// Otherwise they will hit localhost at the specified port. +declare const __DEV_LOCAL_PORT__: number; -// @ts-ignore -const websocketURI = import.meta.env.VITE_INSTANT_DEV - ? 'ws://localhost:8888/runtime/session' - : // @ts-ignore - import.meta.env.VITE_INSTANT_WEBSOCKET_URI || - 'wss://api.instantdb.com/runtime/session'; +const apiUrl = __DEV_LOCAL_PORT__ + ? `http://localhost:${__DEV_LOCAL_PORT__}` + : 'https://api.instantdb.com'; + +const websocketURI = __DEV_LOCAL_PORT__ + ? `ws://localhost:${__DEV_LOCAL_PORT__}/runtime/session` + : 'wss://api.instantdb.com/runtime/session'; // Make a factory function that returns a typed test instance export function makeE2ETest>({ diff --git a/client/packages/core/package.json b/client/packages/core/package.json index 87efe16f85..2a2655dd64 100644 --- a/client/packages/core/package.json +++ b/client/packages/core/package.json @@ -41,6 +41,7 @@ "bench": "vitest bench", "test:types": "tsc -p tsconfig.test.json --noEmit", "test:ci": "vitest run && pnpm run test:types", + "test:e2e": "vitest run --project e2e", "bench:ci": "vitest bench --run", "check": "tsc --noEmit", "check-exports": "attw --pack .", diff --git a/client/packages/core/vitest.config.ts b/client/packages/core/vitest.config.ts index e8bd727396..606d472c3c 100644 --- a/client/packages/core/vitest.config.ts +++ b/client/packages/core/vitest.config.ts @@ -1,11 +1,17 @@ import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; +const devSlot = Number(process.env.DEV_SLOT ?? 0); +const localPort = process.env.CI ? 0 : 8888 + devSlot * 1000; + export default defineConfig({ test: { projects: [ { extends: true, + define: { + __DEV_LOCAL_PORT__: localPort, + }, test: { name: 'e2e', include: ['**/**.e2e.test.ts'], From 11a6c33d6007c65015e65f75e4fb1f6d3e000a09 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 12 Mar 2026 15:09:46 -0700 Subject: [PATCH 10/34] Replace session storage with persistor --- client/packages/core/src/Reactor.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js index a36e506b30..7c0b1d47e8 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -64,7 +64,7 @@ const defaultConfig = { // Param that the backend adds if this is an oauth redirect const OAUTH_REDIRECT_PARAM = '_instant_oauth_redirect'; -const OAUTH_EXTRA_FIELDS_KEY = '_instant_oauth_extra_fields'; +const oauthExtraFieldsKey = 'oauthExtraFields'; const currentUserKey = `currentUser`; @@ -1953,15 +1953,11 @@ export default class Reactor { } this._replaceUrlAfterOAuth(); try { - let extraFields; - if (typeof sessionStorage !== 'undefined') { - try { - const stored = sessionStorage.getItem(OAUTH_EXTRA_FIELDS_KEY); - if (stored) { - extraFields = JSON.parse(stored); - sessionStorage.removeItem(OAUTH_EXTRA_FIELDS_KEY); - } - } catch (_e) {} + const extraFields = await this.kv.waitForKeyToLoad(oauthExtraFieldsKey); + if (extraFields) { + this.kv.updateInPlace((prev) => { + delete prev[oauthExtraFieldsKey]; + }); } const currentUser = await this._getCurrentUser(); const isGuest = currentUser?.type === 'guest'; @@ -2284,11 +2280,10 @@ export default class Reactor { * @returns {string} The created authorization URL. */ createAuthorizationURL({ clientName, redirectURL, extraFields }) { - if (extraFields && typeof sessionStorage !== 'undefined') { - sessionStorage.setItem( - OAUTH_EXTRA_FIELDS_KEY, - JSON.stringify(extraFields), - ); + if (extraFields) { + this.kv.updateInPlace((prev) => { + prev[oauthExtraFieldsKey] = extraFields; + }); } const { apiURI, appId } = this.config; return `${apiURI}/runtime/oauth/start?app_id=${appId}&client_name=${clientName}&redirect_uri=${redirectURL}`; From 43f091e74fad881b31285f0a991b1cac4c338b64 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Tue, 17 Mar 2026 15:49:03 -0700 Subject: [PATCH 11/34] sm lint rules --- client/packages/create-instant-app/template/rules/AGENTS.md | 2 +- .../packages/create-instant-app/template/rules/cursor-rules.md | 2 +- .../create-instant-app/template/rules/windsurf-rules.md | 2 +- client/www/lib/intern/instant-rules.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/packages/create-instant-app/template/rules/AGENTS.md b/client/packages/create-instant-app/template/rules/AGENTS.md index 770fab6b76..06274add5e 100644 --- a/client/packages/create-instant-app/template/rules/AGENTS.md +++ b/client/packages/create-instant-app/template/rules/AGENTS.md @@ -294,7 +294,7 @@ const { user, created } = await db.auth.signInWithMagicCode({ // Scaffold data for new users if (created) { db.transact([ - tx.settings[id()] + db.tx.settings[id()] .update({ theme: 'light', notifications: true }) .link({ user: user.id }), ]); diff --git a/client/packages/create-instant-app/template/rules/cursor-rules.md b/client/packages/create-instant-app/template/rules/cursor-rules.md index f1b361cd58..18f39b6fee 100644 --- a/client/packages/create-instant-app/template/rules/cursor-rules.md +++ b/client/packages/create-instant-app/template/rules/cursor-rules.md @@ -300,7 +300,7 @@ const { user, created } = await db.auth.signInWithMagicCode({ // Scaffold data for new users if (created) { db.transact([ - tx.settings[id()] + db.tx.settings[id()] .update({ theme: 'light', notifications: true }) .link({ user: user.id }), ]); diff --git a/client/packages/create-instant-app/template/rules/windsurf-rules.md b/client/packages/create-instant-app/template/rules/windsurf-rules.md index 5e049bd17f..198894e49e 100644 --- a/client/packages/create-instant-app/template/rules/windsurf-rules.md +++ b/client/packages/create-instant-app/template/rules/windsurf-rules.md @@ -300,7 +300,7 @@ const { user, created } = await db.auth.signInWithMagicCode({ // Scaffold data for new users if (created) { db.transact([ - tx.settings[id()] + db.tx.settings[id()] .update({ theme: 'light', notifications: true }) .link({ user: user.id }), ]); diff --git a/client/www/lib/intern/instant-rules.md b/client/www/lib/intern/instant-rules.md index 770fab6b76..06274add5e 100644 --- a/client/www/lib/intern/instant-rules.md +++ b/client/www/lib/intern/instant-rules.md @@ -294,7 +294,7 @@ const { user, created } = await db.auth.signInWithMagicCode({ // Scaffold data for new users if (created) { db.transact([ - tx.settings[id()] + db.tx.settings[id()] .update({ theme: 'light', notifications: true }) .link({ user: user.id }), ]); From 1f9884915be362f81c6b70115f6178fe8c6bc91a Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Tue, 17 Mar 2026 16:00:45 -0700 Subject: [PATCH 12/34] Clear stale extraFields on OAuth flows without extraFields Co-Authored-By: Claude Opus 4.6 (1M context) --- client/packages/core/src/Reactor.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js index 7c0b1d47e8..34aaad44bd 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -2280,11 +2280,13 @@ export default class Reactor { * @returns {string} The created authorization URL. */ createAuthorizationURL({ clientName, redirectURL, extraFields }) { - if (extraFields) { - this.kv.updateInPlace((prev) => { + this.kv.updateInPlace((prev) => { + if (extraFields) { prev[oauthExtraFieldsKey] = extraFields; - }); - } + } else { + delete prev[oauthExtraFieldsKey]; + } + }); const { apiURI, appId } = this.config; return `${apiURI}/runtime/oauth/start?app_id=${appId}&client_name=${clientName}&redirect_uri=${redirectURL}`; } From 3afcd28d78003384a754f066a1dd435c6245e9dc Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 15:16:14 -0700 Subject: [PATCH 13/34] verifyMagicCode -> consumeMagicCode --- client/packages/admin/src/index.ts | 35 ++++++++++--- .../src/auth-extra-fields.e2e.test.ts | 49 +++++++++++++++++++ client/packages/core/src/Reactor.js | 2 +- client/packages/core/src/authAPI.ts | 44 +++++++++++++++-- client/packages/core/src/index.ts | 14 ++++-- client/packages/react-native/src/index.ts | 4 ++ client/packages/react/src/index.ts | 4 ++ client/packages/solidjs/src/index.ts | 4 ++ client/packages/svelte/src/lib/index.ts | 4 ++ client/www/pages/docs/backend.md | 13 ++--- 10 files changed, 147 insertions(+), 26 deletions(-) diff --git a/client/packages/admin/src/index.ts b/client/packages/admin/src/index.ts index 0f3bb53ac1..4b8a4b15b5 100644 --- a/client/packages/admin/src/index.ts +++ b/client/packages/admin/src/index.ts @@ -507,19 +507,42 @@ class Auth { }; /** - * Verifies a magic code for the user with the given email. + * @deprecated Use {@link consumeMagicCode} instead to get the `created` field + * and support `extraFields`. + * + * @see https://instantdb.com/docs/backend#custom-magic-codes + */ + verifyMagicCode = async (email: string, code: string): Promise => { + const { user } = await jsonFetch( + `${this.config.apiURI}/admin/verify_magic_code?app_id=${this.config.appId}`, + { + method: 'POST', + headers: authorizedHeaders(this.config), + body: JSON.stringify({ email, code }), + }, + ); + return user; + }; + + /** + * Verifies a magic code and returns the user along with whether + * the user was newly created. Supports `extraFields` to set custom + * `$users` properties at signup. * * @example - * const user = await db.auth.verifyMagicCode({ email, code }) - * console.log("Verified user:", user) + * const { user, created } = await db.auth.consumeMagicCode( + * email, + * code, + * { extraFields: { nickname: 'ari' } }, + * ); * * @see https://instantdb.com/docs/backend#custom-magic-codes */ - verifyMagicCode = async ( + consumeMagicCode = async ( email: string, code: string, options?: { extraFields?: Record }, - ): Promise => { + ): Promise<{ user: User; created: boolean }> => { const res = await jsonFetch( `${this.config.apiURI}/admin/verify_magic_code?app_id=${this.config.appId}`, { @@ -534,7 +557,7 @@ class Auth { }), }, ); - return { ...res.user, created: res.created }; + return { user: res.user, created: res.created }; }; /** diff --git a/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts b/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts index aa4b1e7440..adbc1f0544 100644 --- a/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts +++ b/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts @@ -83,3 +83,52 @@ authTest( expect(res.user.email).toBe(email); }, ); + +authTest( + 'admin verify_magic_code returns { user, created } for consumeMagicCode', + async ({ db: _db, appId, adminToken }) => { + const email = `admin-consume-${Date.now()}@test.com`; + const code = await generateMagicCode(appId, adminToken, email); + + // Hit the admin endpoint directly (same as admin SDK consumeMagicCode) + const res = await fetch( + `${apiUrl}/admin/verify_magic_code?app_id=${appId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ + email, + code, + 'extra-fields': { username: 'admin_user' }, + }), + }, + ); + const data = await res.json(); + + // Response should have user nested (not splatted) and created flag + expect(data.user).toBeDefined(); + expect(data.user.email).toBe(email); + expect(data.created).toBe(true); + + // Second call -- existing user + const code2 = await generateMagicCode(appId, adminToken, email); + const res2 = await fetch( + `${apiUrl}/admin/verify_magic_code?app_id=${appId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ email, code: code2 }), + }, + ); + const data2 = await res2.json(); + + expect(data2.user).toBeDefined(); + expect(data2.created).toBe(false); + }, +); diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js index 34aaad44bd..ff3b7e86c6 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -2211,7 +2211,7 @@ export default class Reactor { async signInWithMagicCode(params) { const currentUser = await this.getCurrentUser(); const isGuest = currentUser?.user?.type === 'guest'; - const res = await authAPI.verifyMagicCode({ + const res = await authAPI.consumeMagicCode({ apiURI: this.config.apiURI, appId: this.config.appId, email: params.email, diff --git a/client/packages/core/src/authAPI.ts b/client/packages/core/src/authAPI.ts index db0d35c055..7dbc26004d 100644 --- a/client/packages/core/src/authAPI.ts +++ b/client/packages/core/src/authAPI.ts @@ -27,20 +27,53 @@ export type VerifyMagicCodeParams = { email: string; code: string; refreshToken?: string | undefined; - extraFields?: Record | undefined; }; export type VerifyResponse = { user: User; - created?: boolean; }; + +/** + * @deprecated Use {@link consumeMagicCode} instead to get the `created` field + * and support `extraFields`. + */ export async function verifyMagicCode({ apiURI, appId, email, code, refreshToken, - extraFields, }: SharedInput & VerifyMagicCodeParams): Promise { + const res = await jsonFetch(`${apiURI}/runtime/auth/verify_magic_code`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + 'app-id': appId, + email, + code, + ...(refreshToken ? { 'refresh-token': refreshToken } : {}), + }), + }); + return res; +} + +export type ConsumeMagicCodeParams = { + email: string; + code: string; + refreshToken?: string | undefined; + extraFields?: Record | undefined; +}; +export type ConsumeMagicCodeResponse = { + user: User; + created: boolean; +}; +export async function consumeMagicCode({ + apiURI, + appId, + email, + code, + refreshToken, + extraFields, +}: SharedInput & ConsumeMagicCodeParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/auth/verify_magic_code`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -100,7 +133,8 @@ export async function exchangeCodeForToken({ codeVerifier, refreshToken, extraFields, -}: SharedInput & ExchangeCodeForTokenParams): Promise { +}: SharedInput & + ExchangeCodeForTokenParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -131,7 +165,7 @@ export async function signInWithIdToken({ clientName, refreshToken, extraFields, -}: SharedInput & SignInWithIdTokenParams): Promise { +}: SharedInput & SignInWithIdTokenParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/oauth/id_token`, { method: 'POST', headers: { 'content-type': 'application/json' }, diff --git a/client/packages/core/src/index.ts b/client/packages/core/src/index.ts index e992e60efa..06ec81c088 100644 --- a/client/packages/core/src/index.ts +++ b/client/packages/core/src/index.ts @@ -114,6 +114,8 @@ import type { UploadFileResponse, DeleteFileResponse } from './StorageAPI.ts'; import { FrameworkClient, type FrameworkConfig } from './framework.ts'; import type { + ConsumeMagicCodeParams, + ConsumeMagicCodeResponse, ExchangeCodeForTokenParams, SendMagicCodeParams, SendMagicCodeResponse, @@ -347,8 +349,8 @@ class Auth { * .catch((err) => console.error(err.body?.message)) */ signInWithMagicCode = ( - params: VerifyMagicCodeParams, - ): Promise => { + params: ConsumeMagicCodeParams, + ): Promise => { return this.db.signInWithMagicCode(params); }; @@ -422,7 +424,7 @@ class Auth { */ signInWithIdToken = ( params: SignInWithIdTokenParams, - ): Promise => { + ): Promise => { return this.db.signInWithIdToken(params); }; @@ -442,7 +444,9 @@ class Auth { * .catch((err) => console.error(err.body?.message)); * */ - exchangeOAuthCode = (params: ExchangeCodeForTokenParams) => { + exchangeOAuthCode = ( + params: ExchangeCodeForTokenParams, + ): Promise => { return this.db.exchangeCodeForToken(params); }; @@ -1156,6 +1160,8 @@ export { type InstantDBInferredType, // auth types + type ConsumeMagicCodeParams, + type ConsumeMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, diff --git a/client/packages/react-native/src/index.ts b/client/packages/react-native/src/index.ts index 0933afb1f4..2b44297a30 100644 --- a/client/packages/react-native/src/index.ts +++ b/client/packages/react-native/src/index.ts @@ -74,6 +74,8 @@ import { type UpdateParams, type LinkParams, type ValidQuery, + type ConsumeMagicCodeParams, + type ConsumeMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -247,6 +249,8 @@ export { type UpdateParams, type LinkParams, type ValidQuery, + type ConsumeMagicCodeParams, + type ConsumeMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type TransactionChunk, diff --git a/client/packages/react/src/index.ts b/client/packages/react/src/index.ts index b1cbdac022..9e8a72e5c5 100644 --- a/client/packages/react/src/index.ts +++ b/client/packages/react/src/index.ts @@ -64,6 +64,8 @@ import { type UpdateParams, type LinkParams, type CreateParams, + type ConsumeMagicCodeParams, + type ConsumeMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -174,6 +176,8 @@ export { type UpdateParams, type LinkParams, type CreateParams, + type ConsumeMagicCodeParams, + type ConsumeMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, diff --git a/client/packages/solidjs/src/index.ts b/client/packages/solidjs/src/index.ts index 4c64e940f5..fdb2628935 100644 --- a/client/packages/solidjs/src/index.ts +++ b/client/packages/solidjs/src/index.ts @@ -63,6 +63,8 @@ import { type UpdateParams, type LinkParams, type CreateParams, + type ConsumeMagicCodeParams, + type ConsumeMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -160,6 +162,8 @@ export { type UpdateParams, type LinkParams, type CreateParams, + type ConsumeMagicCodeParams, + type ConsumeMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, diff --git a/client/packages/svelte/src/lib/index.ts b/client/packages/svelte/src/lib/index.ts index fb96a79385..82affa32d6 100644 --- a/client/packages/svelte/src/lib/index.ts +++ b/client/packages/svelte/src/lib/index.ts @@ -63,6 +63,8 @@ import { type UpdateParams, type LinkParams, type CreateParams, + type ConsumeMagicCodeParams, + type ConsumeMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -166,6 +168,8 @@ export { type UpdateParams, type LinkParams, type CreateParams, + type ConsumeMagicCodeParams, + type ConsumeMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, diff --git a/client/www/pages/docs/backend.md b/client/www/pages/docs/backend.md index bc81149471..15cba1b3fc 100644 --- a/client/www/pages/docs/backend.md +++ b/client/www/pages/docs/backend.md @@ -397,24 +397,17 @@ You can also use Instant's default email provider to send a magic code with `db. const { code } = await db.auth.sendMagicCode(req.body.email); ``` -Similarly, you can verify a magic code with `db.auth.verifyMagicCode`. You can pass `extraFields` to set custom `$users` properties when the user is first created. The response includes a `created` boolean. +Similarly, you can verify a magic code with `db.auth.consumeMagicCode`. You can pass `extraFields` to set custom `$users` properties when the user is first created. The response includes a `created` boolean so you can distinguish new users from returning ones. ```typescript {% showCopy=true %} -const { user, created } = await db.auth.verifyMagicCode( +const { user, created } = await db.auth.consumeMagicCode( req.body.email, req.body.code, { extraFields: { nickname: req.body.nickname } }, ); const token = user.refresh_token; -``` - -You can check whether a new user was created via `user.created`: -```typescript {% showCopy=true %} -const user = await db.auth.verifyMagicCode(req.body.email, req.body.code, { - extraFields: { nickname: req.body.nickname }, -}); -if (user.created) { +if (created) { // first-time signup } ``` From 09a80b0283abfb2f8d2673f8aab697c9271dc797 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 15:57:10 -0700 Subject: [PATCH 14/34] Add tests for create behavior --- server/test/instant/runtime/routes_test.clj | 162 ++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/server/test/instant/runtime/routes_test.clj b/server/test/instant/runtime/routes_test.clj index ab5b558f29..82cfe02ebb 100644 --- a/server/test/instant/runtime/routes_test.clj +++ b/server/test/instant/runtime/routes_test.clj @@ -11,6 +11,9 @@ [instant.model.app-oauth-service-provider :as provider-model] [instant.model.app-user :as app-user-model] [instant.postmark :as postmark] + [instant.db.datalog :as d] + [instant.db.permissioned-transaction :as permissioned-tx] + [instant.model.rule :as rule-model] [instant.runtime.routes :as route] [instant.system-catalog :as system-catalog] [instant.util.coll :as coll] @@ -546,3 +549,162 @@ (is (false? (:created body))) (is (= "admin_user" (:username user)))))))) +;; ----- +;; $users create permissions + +(deftest users-create-rule-validation-test + (testing "can save a $users create rule (no validation errors)" + (is (empty? (rule-model/validation-errors + {"$users" {"allow" {"create" "true"}}})))) + + (testing "can still not save a $users delete rule" + (is (seq (rule-model/validation-errors + {"$users" {"allow" {"delete" "true"}}}))))) + +(deftest users-create-rule-magic-code-test + (test-util/test-matrix + [[send-code verify-body] + [[send-code-runtime verify-code-body-runtime]]] + (with-empty-app + (fn [{app-id :id :as app}] + (test-util/make-attrs app-id + [[:$users/username]]) + + (testing "create rule blocks signup" + (rule-model/put! {:app-id app-id + :code {"$users" {"allow" {"create" "false"}}}}) + (let [code (send-code app {:email "blocked@test.com"})] + (is (thrown-with-msg? + ExceptionInfo #"status 403" + (verify-body app {:email "blocked@test.com" + :code code}))) + ;; Magic code should not be consumed on permission failure + ;; so we can retry with the same code after fixing rules + (rule-model/put! {:app-id app-id + :code {"$users" {"allow" {"create" "true"}}}}) + (let [body (verify-body app {:email "blocked@test.com" + :code code})] + (is (true? (:created body))) + (is (= "blocked@test.com" (-> body :user :email)))))) + + (testing "create rule can restrict by email domain" + (rule-model/put! {:app-id app-id + :code {"$users" {"allow" {"create" "data.email.endsWith('@allowed.com')"}}}}) + (let [code (send-code app {:email "nope@blocked.com"})] + (is (thrown-with-msg? + ExceptionInfo #"status 403" + (verify-body app {:email "nope@blocked.com" + :code code})))) + (let [code (send-code app {:email "yes@allowed.com"}) + body (verify-body app {:email "yes@allowed.com" + :code code})] + (is (true? (:created body))))) + + (testing "create rule can block specific extra-fields" + (rule-model/put! {:app-id app-id + :code {"$users" {"allow" {"create" "!('username' in data)"}}}}) + (let [code (send-code app {:email "nofield@test.com"})] + (is (thrown-with-msg? + ExceptionInfo #"status 403" + (verify-body app {:email "nofield@test.com" + :code code + :extra-fields {"username" "sneaky"}})))) + ;; Without the blocked field, signup should succeed + (let [code (send-code app {:email "nofield@test.com"}) + body (verify-body app {:email "nofield@test.com" + :code code})] + (is (true? (:created body))))) + + (testing "default (no create rule) allows signup" + (rule-model/put! {:app-id app-id :code {}}) + (let [code (send-code app {:email "default@test.com"}) + body (verify-body app {:email "default@test.com" + :code code})] + (is (true? (:created body))))) + + (testing "create rule does not run for existing users" + (rule-model/put! {:app-id app-id + :code {"$users" {"allow" {"create" "false"}}}}) + ;; default@test.com already exists from previous test + (let [code (send-code app {:email "default@test.com"}) + body (verify-body app {:email "default@test.com" + :code code})] + (is (false? (:created body))))))))) + +(deftest users-create-rule-admin-bypass-test + (with-empty-app + (fn [{app-id :id :as app}] + (testing "admin SDK bypasses create rule" + (rule-model/put! {:app-id app-id + :code {"$users" {"allow" {"create" "false"}}}}) + (let [code (post-code-admin app {:email "admin-bypass@test.com"}) + body (verify-code-body-admin app {:email "admin-bypass@test.com" + :code code})] + (is (true? (:created body))) + (is (= "admin-bypass@test.com" (-> body :user :email)))))))) + +(deftest users-create-rule-oauth-test + (with-empty-app + (fn [{app-id :id :as app}] + (test-util/make-attrs app-id + [[:$users/username]]) + (let [provider (provider-model/create! {:app-id app-id + :provider-name "clerk"})] + + (testing "create rule blocks oauth signup" + (rule-model/put! {:app-id app-id + :code {"$users" {"allow" {"create" "false"}}}}) + (is (thrown-with-msg? + ExceptionInfo #"permission" + (route/upsert-oauth-link! {:email "oauth-blocked@test.com" + :sub "oauth-sub-blocked" + :app-id app-id + :provider-id (:id provider)})))) + + (testing "create rule allows oauth signup when passing" + (rule-model/put! {:app-id app-id + :code {"$users" {"allow" {"create" "true"}}}}) + (let [result (route/upsert-oauth-link! {:email "oauth-ok@test.com" + :sub "oauth-sub-ok" + :app-id app-id + :provider-id (:id provider)})] + (is (true? (:created result))))))))) + +(deftest users-create-rule-guest-test + (with-empty-app + (fn [{app-id :id :as app}] + (testing "create rule blocks guest signup" + (rule-model/put! {:app-id app-id + :code {"$users" {"allow" {"create" "false"}}}}) + (is (thrown-with-msg? + ExceptionInfo #"status 403" + (sign-in-guest-runtime app)))) + + (testing "create rule allows guest signup when passing" + (rule-model/put! {:app-id app-id + :code {"$users" {"allow" {"create" "true"}}}}) + (let [guest (sign-in-guest-runtime app)] + (is (= "guest" (:type guest)))))))) + +(deftest users-create-rule-transact-blocked-test + (with-empty-app + (fn [{app-id :id}] + (testing "$users creation via transact is blocked even with create rule set to true" + (rule-model/put! {:app-id app-id + :code {"$users" {"allow" {"create" "true"}}}}) + (let [user-id (random-uuid) + attrs (attr-model/get-by-app-id app-id) + id-attr (attr-model/seek-by-fwd-ident-name ["$users" "id"] attrs) + ctx {:db {:conn-pool (aurora/conn-pool :write)} + :app-id app-id + :attrs attrs + :datalog-query-fn d/query + :rules (rule-model/get-by-app-id + (aurora/conn-pool :read) {:app-id app-id}) + :current-user nil}] + (is (thrown-with-msg? + ExceptionInfo #"not allowed" + (permissioned-tx/transact! + ctx + [[:add-triple user-id (:id id-attr) user-id]])))))))) + From 7483758f39e94a7b0370b6b2d4c794835cd37c75 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 16:57:05 -0700 Subject: [PATCH 15/34] Implement auth check for create --- server/src/instant/admin/routes.clj | 3 +- .../instant/db/permissioned_transaction.clj | 14 +++- server/src/instant/model/app_user.clj | 22 +++++++ server/src/instant/model/rule.clj | 22 +++++-- .../src/instant/runtime/magic_code_auth.clj | 64 +++++++++++-------- server/src/instant/runtime/routes.clj | 16 ++++- server/test/instant/runtime/routes_test.clj | 21 +++--- 7 files changed, 118 insertions(+), 44 deletions(-) diff --git a/server/src/instant/admin/routes.clj b/server/src/instant/admin/routes.clj index 67b8536483..0711f33428 100644 --- a/server/src/instant/admin/routes.clj +++ b/server/src/instant/admin/routes.clj @@ -525,7 +525,8 @@ :email email :code code :guest-user-id (:id guest-user) - :extra-fields extra-fields})] + :extra-fields extra-fields + :admin? true})] (response/ok {:user (dissoc result :created) :created (:created result)}))) diff --git a/server/src/instant/db/permissioned_transaction.clj b/server/src/instant/db/permissioned_transaction.clj index bfa7b6a1b0..958dcf15e1 100644 --- a/server/src/instant/db/permissioned_transaction.clj +++ b/server/src/instant/db/permissioned_transaction.clj @@ -46,6 +46,16 @@ (throw-tx-step-validation-err! tx-step (format "%s is a system entity. You aren't allowed to delete this directly." etype)))) +(defn- validate-system-create-entity! [{:keys [admin? attrs]} {:keys [aid eid] :as tx-step}] + (let [attr (attr-model/seek-by-id aid attrs) + [etype label] (attr-model/fwd-ident-name attr)] + (when (and (= "$users" etype) + (= "id" label) + (not admin?)) + (throw-tx-step-validation-err! + tx-step + "$users is a system entity. You aren't allowed to create this directly.")))) + (defn- validate-system-triple-op! [{:keys [admin? attrs]} {:keys [aid] :as tx-step}] (let [attr (attr-model/seek-by-id aid attrs) {:keys [catalog]} attr @@ -107,7 +117,9 @@ :when (#{:add-triple :deep-merge-triple :retract-triple :delete-entity} op)] (if (= :delete-entity op) (validate-system-delete-entity! ctx tx-step) - (validate-system-triple-op! ctx tx-step)))) + (do + (validate-system-create-entity! ctx tx-step) + (validate-system-triple-op! ctx tx-step))))) (defn coerce-value-uuids "Checks that all ref values are either lookup refs or UUIDs" diff --git a/server/src/instant/model/app_user.clj b/server/src/instant/model/app_user.clj index 677250c0be..bc40a32f44 100644 --- a/server/src/instant/model/app_user.clj +++ b/server/src/instant/model/app_user.clj @@ -1,10 +1,13 @@ (ns instant.model.app-user (:require + [instant.db.cel :as cel] + [instant.db.datalog :as d] [instant.db.model.attr :as attr-model] [instant.jdbc.aurora :as aurora] [instant.model.app :as app-model] [instant.model.instant-user :as instant-user-model] [instant.model.app-user-refresh-token :refer [hash-token]] + [instant.model.rule :as rule-model] [instant.system-catalog-ops :refer [update-op query-op]] [instant.util.exception :as ex]) (:import @@ -32,6 +35,25 @@ extra-fields [{:message (format "Cannot set system field: %s" k-str)}]))))))) +(defn assert-create-permission! + "Checks the $users create rule against the user data being created. + Used during auth signup flows. If no create rule is set, allows by default. + Both `auth` and `data` are set to the user being created." + [{:keys [app-id user-data]}] + (let [rules (rule-model/get-by-app-id {:app-id app-id}) + program (rule-model/get-program! rules "$users" "create")] + (when program + (let [ctx {:db {:conn-pool (aurora/conn-pool :read)} + :app-id app-id + :attrs (attr-model/get-by-app-id app-id) + :datalog-query-fn d/query + :current-user user-data}] + (ex/assert-permitted! + :has-signup-permission? + ["$users" "create"] + (cel/eval-program! ctx program {:data user-data + :new-data user-data})))))) + (defn create! ([params] (create! (aurora/conn-pool :write) params)) diff --git a/server/src/instant/model/rule.clj b/server/src/instant/model/rule.clj index bda04f667b..65d4c96f1a 100644 --- a/server/src/instant/model/rule.clj +++ b/server/src/instant/model/rule.clj @@ -180,6 +180,19 @@ (when (contains? system-catalog/all-etypes etype) (let [compiler (cel/action->compiler action)] (cond + ;; Default to allowing signup. $users creation via transactions + ;; is blocked by validate-system-create-entity!. + (and (= "$users" etype) + (= "create" action)) + (let [code "true" + ast (cel/->ast compiler code)] + {:etype etype + :action action + :code code + :display-code code + :cel-ast ast + :cel-program (cel/->program ast)}) + (and (= "$users" etype) (#{"view" "update"} action)) @@ -291,11 +304,12 @@ :paths [path]})))) (defn $users-validation-errors - "Only allow users to changes the `view` and `update` rules for $users, since we don't have - a way to create or delete them from transactions." + "Allow users to set `view`, `update`, and `create` rules for $users. + `create` runs during auth signup flows (not via transactions). + `delete` is still blocked." [rules action] (case action - ("create" "delete") + "delete" (when (and (not (nil? (get-in rules ["$users" "allow" action]))) (not= (get-in rules ["$users" "allow" action]) "false")) @@ -303,7 +317,7 @@ "$users" action "$users" action) :in ["$users" "allow" action]}]) - ("update" "view") nil)) + ("create" "update" "view") nil)) (defn system-attribute-validation-errors "Don't allow users to change rules for restricted system namespaces." diff --git a/server/src/instant/runtime/magic_code_auth.clj b/server/src/instant/runtime/magic_code_auth.clj index 682bd42e39..cfb7588d26 100644 --- a/server/src/instant/runtime/magic_code_auth.clj +++ b/server/src/instant/runtime/magic_code_auth.clj @@ -129,35 +129,49 @@ "Consumes the code and if the code is good, upserts the user. If a guest-user-id is passed in, it will either upgrade the guest user - or link it to the existing user for the email." - [{:keys [app-id email code guest-user-id extra-fields]}] - (app-user-magic-code-model/consume! - {:app-id app-id - :code code - :email email}) + or link it to the existing user for the email. + + Permission check runs before consuming the code so that a failed check + doesn't burn the one-time code." + [{:keys [app-id email code guest-user-id extra-fields admin?]}] (let [existing-user (app-user-model/get-by-email {:app-id app-id :email email}) created? (nil? existing-user) - user (or existing-user - (app-user-model/create! - {:id (or guest-user-id (random-uuid)) - :app-id app-id - :email email - :type "user" - :extra-fields extra-fields})) - refresh-token-id (random-uuid)] - (when (and guest-user-id - (not= (:id user) - guest-user-id)) - (app-user-model/link-guest {:app-id app-id - :primary-user-id (:id user) - :guest-user-id guest-user-id})) - (app-user-refresh-token-model/create! - {:app-id app-id - :id refresh-token-id - :user-id (:id user)}) - (assoc user :refresh_token refresh-token-id :created created?))) + user-id (or guest-user-id (random-uuid))] + ;; Check create permission before consuming the code + (when (and created? (not admin?)) + (let [user-data (cond-> {"email" email + "id" (str user-id)} + extra-fields (merge (into {} + (map (fn [[k v]] [(name k) v])) + extra-fields)))] + (app-user-model/assert-create-permission! + {:app-id app-id + :user-data user-data}))) + (app-user-magic-code-model/consume! + {:app-id app-id + :code code + :email email}) + (let [user (or existing-user + (app-user-model/create! + {:id user-id + :app-id app-id + :email email + :type "user" + :extra-fields extra-fields})) + refresh-token-id (random-uuid)] + (when (and guest-user-id + (not= (:id user) + guest-user-id)) + (app-user-model/link-guest {:app-id app-id + :primary-user-id (:id user) + :guest-user-id guest-user-id})) + (app-user-refresh-token-model/create! + {:app-id app-id + :id refresh-token-id + :user-id (:id user)}) + (assoc user :refresh_token refresh-token-id :created created?)))) (comment (def instant-user (instant-user-model/get-by-email diff --git a/server/src/instant/runtime/routes.clj b/server/src/instant/runtime/routes.clj index bd9faf0085..41dea837dc 100644 --- a/server/src/instant/runtime/routes.clj +++ b/server/src/instant/runtime/routes.clj @@ -130,13 +130,15 @@ (defn sign-in-guest-post [req] (let [app-id (ex/get-param! req [:body :app-id] uuid-util/coerce) - ;; create guest user user-id (random-uuid) + user-data {"id" (str user-id)} + _ (app-user-model/assert-create-permission! + {:app-id app-id + :user-data user-data}) user (app-user-model/create! {:app-id app-id :id user-id :type "guest"}) - ;; create refresh-token for user refresh-token (random-uuid) _ (app-user-refresh-token-model/create! {:app-id app-id @@ -284,7 +286,15 @@ :sub (:app_user_oauth_links/sub oauth-link)})})))] (if-not user - (let [new-user (app-user-model/create! + (let [user-data (cond-> {"email" email + "id" (str (or guest-user-id (random-uuid)))} + extra-fields (merge (into {} + (map (fn [[k v]] [(name k) v])) + extra-fields))) + _ (app-user-model/assert-create-permission! + {:app-id app-id + :user-data user-data}) + new-user (app-user-model/create! {:id guest-user-id :app-id app-id :email email diff --git a/server/test/instant/runtime/routes_test.clj b/server/test/instant/runtime/routes_test.clj index 82cfe02ebb..1a57a50487 100644 --- a/server/test/instant/runtime/routes_test.clj +++ b/server/test/instant/runtime/routes_test.clj @@ -575,7 +575,7 @@ :code {"$users" {"allow" {"create" "false"}}}}) (let [code (send-code app {:email "blocked@test.com"})] (is (thrown-with-msg? - ExceptionInfo #"status 403" + ExceptionInfo #"status 400" (verify-body app {:email "blocked@test.com" :code code}))) ;; Magic code should not be consumed on permission failure @@ -592,7 +592,7 @@ :code {"$users" {"allow" {"create" "data.email.endsWith('@allowed.com')"}}}}) (let [code (send-code app {:email "nope@blocked.com"})] (is (thrown-with-msg? - ExceptionInfo #"status 403" + ExceptionInfo #"status 400" (verify-body app {:email "nope@blocked.com" :code code})))) (let [code (send-code app {:email "yes@allowed.com"}) @@ -600,19 +600,20 @@ :code code})] (is (true? (:created body))))) - (testing "create rule can block specific extra-fields" + (testing "create rule can validate extra-fields values" (rule-model/put! {:app-id app-id - :code {"$users" {"allow" {"create" "!('username' in data)"}}}}) + :code {"$users" {"allow" {"create" "data.username == null || data.username.size() >= 3"}}}}) (let [code (send-code app {:email "nofield@test.com"})] (is (thrown-with-msg? - ExceptionInfo #"status 403" + ExceptionInfo #"status 400" (verify-body app {:email "nofield@test.com" :code code - :extra-fields {"username" "sneaky"}})))) - ;; Without the blocked field, signup should succeed + :extra-fields {"username" "ab"}})))) + ;; Valid username should succeed (let [code (send-code app {:email "nofield@test.com"}) body (verify-body app {:email "nofield@test.com" - :code code})] + :code code + :extra-fields {"username" "valid_user"}})] (is (true? (:created body))))) (testing "default (no create rule) allows signup" @@ -677,7 +678,7 @@ (rule-model/put! {:app-id app-id :code {"$users" {"allow" {"create" "false"}}}}) (is (thrown-with-msg? - ExceptionInfo #"status 403" + ExceptionInfo #"status 400" (sign-in-guest-runtime app)))) (testing "create rule allows guest signup when passing" @@ -703,7 +704,7 @@ (aurora/conn-pool :read) {:app-id app-id}) :current-user nil}] (is (thrown-with-msg? - ExceptionInfo #"not allowed" + ExceptionInfo #"system entity" (permissioned-tx/transact! ctx [[:add-triple user-id (:id id-attr) user-id]])))))))) From 4978874c84562328c06c9d02ad21dbabcf8c5e2f Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 17:01:51 -0700 Subject: [PATCH 16/34] Add perms for create to docs --- client/www/pages/docs/users.md | 46 +++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/client/www/pages/docs/users.md b/client/www/pages/docs/users.md index 4a9732f5ee..174bc418dd 100644 --- a/client/www/pages/docs/users.md +++ b/client/www/pages/docs/users.md @@ -35,7 +35,7 @@ const rules = { export default rules; ``` -Since `$users` is a managed namespace, you can override `view` and `update` rules, but not `create` or `delete`. These are handled by the Instant backend. +Since `$users` is a managed namespace, you can override `view`, `update`, and `create` rules, but not `delete`. The `create` rule runs during signup (not via `transact`) and can be used to restrict who can sign up or validate `extraFields`. See [Signup rules](#signup-rules) for details. ## Sharing user data @@ -244,6 +244,50 @@ if (created) { } ``` +## Signup rules + +You can write a `create` rule on `$users` to control who can sign up and what fields they can set. This rule runs during the auth signup flow (magic codes, OAuth, guest sign-in) but does not apply to `transact`. + +By default, anyone can sign up. If you set a `create` rule, it must pass for signup to succeed. If it fails, no user is created and magic codes are not consumed. + +The `create` rule has access to `data` (the user being created, including email and any `extraFields`) and `auth` (set to the same value as `data`). Note that `ref()` is not available since the user has no relationships yet. + +**Restrict signups to a domain** + +```javascript +{ + "$users": { + "allow": { + "create": "data.email.endsWith('@mycompany.com')" + } + } +} +``` + +**Validate extraFields values** + +```javascript +{ + "$users": { + "allow": { + "create": "data.username == null || data.username.size() >= 3" + } + } +} +``` + +**Disable all signups (waitlist mode)** + +```javascript +{ + "$users": { + "allow": { + "create": "false" + } + } +} +``` + ## User permissions You can reference the `$users` namespace in your permission rules just like a From 5a6004b807b3dd68654562c876c661aa67025592 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 17:03:03 -0700 Subject: [PATCH 17/34] Update rules with info on updating create rule --- .../packages/create-instant-app/template/rules/AGENTS.md | 8 +++++--- .../create-instant-app/template/rules/cursor-rules.md | 8 +++++--- .../create-instant-app/template/rules/windsurf-rules.md | 8 +++++--- client/www/lib/intern/instant-rules.md | 8 +++++--- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/client/packages/create-instant-app/template/rules/AGENTS.md b/client/packages/create-instant-app/template/rules/AGENTS.md index 06274add5e..10423d933f 100644 --- a/client/packages/create-instant-app/template/rules/AGENTS.md +++ b/client/packages/create-instant-app/template/rules/AGENTS.md @@ -176,9 +176,11 @@ data.ref(someVar + '.members.id') ## $users Permissions - Default `view` permission is `auth.id == data.id` -- Default `create`, `update`, and `delete` permissions is false -- Can override `view` and `update` -- Cannot override `create` or `delete` +- Default `update` and `delete` permissions is false +- Default `create` permission is true (anyone can sign up) +- Can override `view`, `update`, and `create` +- Cannot override `delete` +- The `create` rule runs during auth signup flows (not via `transact`). Use it to restrict signups or validate `extraFields`. ## $files Permissions diff --git a/client/packages/create-instant-app/template/rules/cursor-rules.md b/client/packages/create-instant-app/template/rules/cursor-rules.md index 18f39b6fee..54299c5e1b 100644 --- a/client/packages/create-instant-app/template/rules/cursor-rules.md +++ b/client/packages/create-instant-app/template/rules/cursor-rules.md @@ -182,9 +182,11 @@ data.ref(someVar + '.members.id') ## $users Permissions - Default `view` permission is `auth.id == data.id` -- Default `create`, `update`, and `delete` permissions is false -- Can override `view` and `update` -- Cannot override `create` or `delete` +- Default `update` and `delete` permissions is false +- Default `create` permission is true (anyone can sign up) +- Can override `view`, `update`, and `create` +- Cannot override `delete` +- The `create` rule runs during auth signup flows (not via `transact`). Use it to restrict signups or validate `extraFields`. ## $files Permissions diff --git a/client/packages/create-instant-app/template/rules/windsurf-rules.md b/client/packages/create-instant-app/template/rules/windsurf-rules.md index 198894e49e..71952cdb9c 100644 --- a/client/packages/create-instant-app/template/rules/windsurf-rules.md +++ b/client/packages/create-instant-app/template/rules/windsurf-rules.md @@ -182,9 +182,11 @@ data.ref(someVar + '.members.id') ## $users Permissions - Default `view` permission is `auth.id == data.id` -- Default `create`, `update`, and `delete` permissions is false -- Can override `view` and `update` -- Cannot override `create` or `delete` +- Default `update` and `delete` permissions is false +- Default `create` permission is true (anyone can sign up) +- Can override `view`, `update`, and `create` +- Cannot override `delete` +- The `create` rule runs during auth signup flows (not via `transact`). Use it to restrict signups or validate `extraFields`. ## $files Permissions diff --git a/client/www/lib/intern/instant-rules.md b/client/www/lib/intern/instant-rules.md index 06274add5e..10423d933f 100644 --- a/client/www/lib/intern/instant-rules.md +++ b/client/www/lib/intern/instant-rules.md @@ -176,9 +176,11 @@ data.ref(someVar + '.members.id') ## $users Permissions - Default `view` permission is `auth.id == data.id` -- Default `create`, `update`, and `delete` permissions is false -- Can override `view` and `update` -- Cannot override `create` or `delete` +- Default `update` and `delete` permissions is false +- Default `create` permission is true (anyone can sign up) +- Can override `view`, `update`, and `create` +- Cannot override `delete` +- The `create` rule runs during auth signup flows (not via `transact`). Use it to restrict signups or validate `extraFields`. ## $files Permissions From 16f0e4d5bfc9dd51e02742eab77a17392b39d9b5 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 17:10:23 -0700 Subject: [PATCH 18/34] Add sandbox test for extra fields --- .../react-nextjs/pages/play/signup-rules.tsx | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 client/sandbox/react-nextjs/pages/play/signup-rules.tsx diff --git a/client/sandbox/react-nextjs/pages/play/signup-rules.tsx b/client/sandbox/react-nextjs/pages/play/signup-rules.tsx new file mode 100644 index 0000000000..435f57939c --- /dev/null +++ b/client/sandbox/react-nextjs/pages/play/signup-rules.tsx @@ -0,0 +1,269 @@ +import { i, User, InstantReactAbstractDatabase } from '@instantdb/react'; +import { useState } from 'react'; +import EphemeralAppPage from '../../components/EphemeralAppPage'; +import config from '../../config'; +import Link from 'next/link'; + +const schema = i.schema({ + entities: { + $users: i.entity({ + email: i.string().unique().indexed().optional(), + username: i.string().optional(), + displayName: i.string().optional(), + }), + }, +}); + +// Restrict signup to @allowed.com emails and require username >= 3 chars +const perms = { + $users: { + allow: { + create: + "data.email.endsWith('@allowed.com') && (data.username == null || data.username.size() >= 3)", + }, + }, +}; + +function getAdminToken(appId: string): string | null { + try { + return localStorage.getItem(`ephemeral-admin-token-${appId}`); + } catch { + return null; + } +} + +function App({ + db, + appId, +}: { + db: InstantReactAbstractDatabase; + appId: string; +}) { + const { isLoading, user, error } = db.useAuth(); + if (isLoading) return
Loading...
; + if (error) { + return ( +
+
Uh oh! {error.message}
+ +
+ ); + } + if (user) return
; + return ; +} + +function Login({ + db, + appId, +}: { + db: InstantReactAbstractDatabase; + appId: string; +}) { + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [username, setUsername] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [sentTo, setSentTo] = useState(null); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const adminToken = getAdminToken(appId); + + return ( +
+ {'<-'} Home +

Signup Rules Test

+ +
+

Active create rule:

+
+          {`data.email.endsWith('@allowed.com')\n  && (data.username == null || data.username.size() >= 3)`}
+        
+

+ Try signing up with a non-@allowed.com email, or with a username + shorter than 3 characters. +

+
+ +
+
+ + setUsername(e.target.value)} + placeholder="Optional, min 3 chars" + className="mt-1 w-full rounded border px-3 py-2" + /> +
+
+ + setDisplayName(e.target.value)} + placeholder="Optional" + className="mt-1 w-full rounded border px-3 py-2" + /> +
+
+ + {!sentTo ? ( +
{ + e.preventDefault(); + setError(null); + try { + if (adminToken) { + const res = await fetch(`${config.apiURI}/admin/magic_code`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'app-id': appId, + authorization: `Bearer ${adminToken}`, + }, + body: JSON.stringify({ email }), + }); + const data = await res.json(); + setSentTo(email); + setCode(data.code); + } else { + await db.auth.sendMagicCode({ email }); + setSentTo(email); + } + } catch (err: any) { + setError(err.body?.message || err.message); + } + }} + > + setEmail(e.target.value)} + placeholder="Email (try @allowed.com vs other)" + className="w-full rounded border px-3 py-2" + /> + +
+ ) : ( +
{ + e.preventDefault(); + setError(null); + setResult(null); + try { + const extraFields: Record = {}; + if (username) extraFields.username = username; + if (displayName) extraFields.displayName = displayName; + + const res = await db.auth.signInWithMagicCode({ + email: sentTo!, + code, + extraFields: + Object.keys(extraFields).length > 0 ? extraFields : undefined, + }); + setResult(`created: ${res.created}, user: ${res.user.email}`); + } catch (err: any) { + setError(err.body?.message || err.message); + } + }} + > +

Code sent to {sentTo}

+ setCode(e.target.value)} + placeholder="Enter code" + className="w-full rounded border px-3 py-2" + /> +
+ + +
+ + )} + {error && ( +
+ {error} +
+ )} + {result && ( +
+ {result} +
+ )} +
+ ); +} + +function Main({ + db, + user, +}: { + db: InstantReactAbstractDatabase; + user: User; +}) { + const { data } = db.useQuery({ $users: {} }); + const currentUser = data?.$users?.find((u: any) => u.id === user.id); + + return ( +
+ {'<-'} Home +

Signed in!

+
+

+ Email: {user.email} +

+

+ ID: {user.id} +

+ {currentUser && ( +
+

$users record:

+
+              {JSON.stringify(currentUser, null, 2)}
+            
+
+ )} +
+ +
+ ); +} + +export default function Page() { + return ; +} From d67f7caaa0896293a1489ff7c17c478e1f3038ad Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 17:23:34 -0700 Subject: [PATCH 19/34] restrict extra fields checks on signup only --- server/src/instant/admin/routes.clj | 24 +++++++++---------- .../src/instant/runtime/magic_code_auth.clj | 23 ++++++++++-------- server/src/instant/runtime/routes.clj | 6 ++--- server/test/instant/runtime/routes_test.clj | 11 ++++++++- 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/server/src/instant/admin/routes.clj b/server/src/instant/admin/routes.clj index 0711f33428..252e512019 100644 --- a/server/src/instant/admin/routes.clj +++ b/server/src/instant/admin/routes.clj @@ -398,7 +398,6 @@ email (ex/get-optional-param! req [:body :email] email/coerce) id (ex/get-optional-param! req [:body :id] uuid-util/coerce) extra-fields (get-in req [:body :extra-fields]) - _ (app-user-model/validate-extra-fields! app-id extra-fields) existing-user (cond @@ -413,16 +412,18 @@ {user-id :id :as user} (or existing-user - (cond - email (app-user-model/create! - {:id (UUID/randomUUID) - :app-id app-id - :email email - :extra-fields extra-fields}) - id (app-user-model/create! - {:id id - :app-id app-id - :extra-fields extra-fields}))) + (do + (app-user-model/validate-extra-fields! app-id extra-fields) + (cond + email (app-user-model/create! + {:id (UUID/randomUUID) + :app-id app-id + :email email + :extra-fields extra-fields}) + id (app-user-model/create! + {:id id + :app-id app-id + :extra-fields extra-fields})))) {refresh-token-id :id} (app-user-refresh-token-model/create! @@ -513,7 +514,6 @@ email (ex/get-param! req [:body :email] email/coerce) code (ex/get-param! req [:body :code] string-util/safe-trim) extra-fields (get-in req [:body :extra-fields]) - _ (app-user-model/validate-extra-fields! app-id extra-fields) guest-user (when-some [refresh-token (ex/get-optional-param! req [:body :refresh-token] uuid-util/coerce)] (let [user (app-user-model/get-by-refresh-token! {:app-id app-id diff --git a/server/src/instant/runtime/magic_code_auth.clj b/server/src/instant/runtime/magic_code_auth.clj index cfb7588d26..a3cef652cc 100644 --- a/server/src/instant/runtime/magic_code_auth.clj +++ b/server/src/instant/runtime/magic_code_auth.clj @@ -139,16 +139,19 @@ :email email}) created? (nil? existing-user) user-id (or guest-user-id (random-uuid))] - ;; Check create permission before consuming the code - (when (and created? (not admin?)) - (let [user-data (cond-> {"email" email - "id" (str user-id)} - extra-fields (merge (into {} - (map (fn [[k v]] [(name k) v])) - extra-fields)))] - (app-user-model/assert-create-permission! - {:app-id app-id - :user-data user-data}))) + ;; Only validate extra-fields and check create permission for new users. + ;; Returning users ignore extra-fields so we skip validation for them. + (when created? + (app-user-model/validate-extra-fields! app-id extra-fields) + (when-not admin? + (let [user-data (cond-> {"email" email + "id" (str user-id)} + extra-fields (merge (into {} + (map (fn [[k v]] [(name k) v])) + extra-fields)))] + (app-user-model/assert-create-permission! + {:app-id app-id + :user-data user-data})))) (app-user-magic-code-model/consume! {:app-id app-id :code code diff --git a/server/src/instant/runtime/routes.clj b/server/src/instant/runtime/routes.clj index 41dea837dc..6010863f95 100644 --- a/server/src/instant/runtime/routes.clj +++ b/server/src/instant/runtime/routes.clj @@ -95,7 +95,6 @@ code (ex/get-param! req [:body :code] string-util/safe-trim) app-id (ex/get-param! req [:body :app-id] uuid-util/coerce) extra-fields (get-in req [:body :extra-fields]) - _ (app-user-model/validate-extra-fields! app-id extra-fields) guest-user (when-some [refresh-token (ex/get-optional-param! req [:body :refresh-token] uuid-util/coerce)] (let [user (app-user-model/get-by-refresh-token! {:app-id app-id @@ -286,7 +285,8 @@ :sub (:app_user_oauth_links/sub oauth-link)})})))] (if-not user - (let [user-data (cond-> {"email" email + (let [_ (app-user-model/validate-extra-fields! app-id extra-fields) + user-data (cond-> {"email" email "id" (str (or guest-user-id (random-uuid)))} extra-fields (merge (into {} (map (fn [[k v]] [(name k) v])) @@ -606,7 +606,6 @@ {:keys [app_id client_id user_info]} oauth-code _ (assert (= app-id app_id) (str "(= " app-id " " app_id ")")) - _ (app-user-model/validate-extra-fields! app-id extra-fields) client (or (app-oauth-client-model/get-by-id {:app-id app-id :id client_id}) @@ -651,7 +650,6 @@ current-refresh-token-id (ex/get-optional-param! req [:body :refresh_token] uuid-util/coerce) client-name (ex/get-param! req [:body :client_name] string-util/coerce-non-blank-str) extra-fields (get-in req [:body :extra_fields]) - _ (app-user-model/validate-extra-fields! app-id extra-fields) client (app-oauth-client-model/get-by-client-name! {:app-id app-id :client-name client-name}) oauth-client (app-oauth-client-model/->OAuthClient client) diff --git a/server/test/instant/runtime/routes_test.clj b/server/test/instant/runtime/routes_test.clj index 1a57a50487..46dad87457 100644 --- a/server/test/instant/runtime/routes_test.clj +++ b/server/test/instant/runtime/routes_test.clj @@ -456,7 +456,16 @@ ExceptionInfo #"status 400" (verify-body app {:email "sys@test.com" :code code - :extra-fields {"email" "evil@test.com"}}))))))))) + :extra-fields {"email" "evil@test.com"}}))))) + + ;; new@test.com was created in the "new user with extra-fields" test above + (testing "returning user with invalid extra-fields still signs in" + (let [code (send-code app {:email "new@test.com"}) + body (verify-body app {:email "new@test.com" + :code code + :extra-fields {"nonexistent" "value"}})] + (is (false? (:created body))) + (is (= "new@test.com" (-> body :user :email))))))))) (deftest extra-fields-guest-upgrade-test (test-util/test-matrix From 5eb110512872875f6795a35c814766c592e14cba Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 17:26:19 -0700 Subject: [PATCH 20/34] sm docs --- client/www/pages/docs/users.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/www/pages/docs/users.md b/client/www/pages/docs/users.md index 174bc418dd..636c44a1db 100644 --- a/client/www/pages/docs/users.md +++ b/client/www/pages/docs/users.md @@ -237,7 +237,7 @@ const { user, created } = await db.auth.signInWithMagicCode({ if (created) { // Create default data for the new user db.transact([ - tx.settings[id()] + db.tx.settings[id()] .update({ theme: 'light', notifications: true }) .link({ user: user.id }), ]); From 837e5cfb7e54f4d62e9dcfc26800b13b40e5a790 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 17:26:50 -0700 Subject: [PATCH 21/34] clojure lint --- server/src/instant/db/permissioned_transaction.clj | 2 +- server/test/instant/runtime/routes_test.clj | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/instant/db/permissioned_transaction.clj b/server/src/instant/db/permissioned_transaction.clj index 958dcf15e1..5467f846ef 100644 --- a/server/src/instant/db/permissioned_transaction.clj +++ b/server/src/instant/db/permissioned_transaction.clj @@ -46,7 +46,7 @@ (throw-tx-step-validation-err! tx-step (format "%s is a system entity. You aren't allowed to delete this directly." etype)))) -(defn- validate-system-create-entity! [{:keys [admin? attrs]} {:keys [aid eid] :as tx-step}] +(defn- validate-system-create-entity! [{:keys [admin? attrs]} {:keys [aid] :as tx-step}] (let [attr (attr-model/seek-by-id aid attrs) [etype label] (attr-model/fwd-ident-name attr)] (when (and (= "$users" etype) diff --git a/server/test/instant/runtime/routes_test.clj b/server/test/instant/runtime/routes_test.clj index 46dad87457..63c80acb18 100644 --- a/server/test/instant/runtime/routes_test.clj +++ b/server/test/instant/runtime/routes_test.clj @@ -494,7 +494,7 @@ (deftest extra-fields-oauth-test (with-empty-app - (fn [{app-id :id :as app}] + (fn [{app-id :id}] (test-util/make-attrs app-id [[:$users/username] [:$users/displayName]]) @@ -655,7 +655,7 @@ (deftest users-create-rule-oauth-test (with-empty-app - (fn [{app-id :id :as app}] + (fn [{app-id :id}] (test-util/make-attrs app-id [[:$users/username]]) (let [provider (provider-model/create! {:app-id app-id From 702a47d7a404e4482c12076be9a3a6acdfb26854 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 17:53:52 -0700 Subject: [PATCH 22/34] cr --- .../src/auth-extra-fields.e2e.test.ts | 48 ------------------- client/www/pages/docs/http-api.md | 2 +- .../instant/db/permissioned_transaction.clj | 43 +++++++---------- 3 files changed, 19 insertions(+), 74 deletions(-) diff --git a/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts b/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts index adbc1f0544..2d6b469441 100644 --- a/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts +++ b/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts @@ -84,51 +84,3 @@ authTest( }, ); -authTest( - 'admin verify_magic_code returns { user, created } for consumeMagicCode', - async ({ db: _db, appId, adminToken }) => { - const email = `admin-consume-${Date.now()}@test.com`; - const code = await generateMagicCode(appId, adminToken, email); - - // Hit the admin endpoint directly (same as admin SDK consumeMagicCode) - const res = await fetch( - `${apiUrl}/admin/verify_magic_code?app_id=${appId}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - authorization: `Bearer ${adminToken}`, - }, - body: JSON.stringify({ - email, - code, - 'extra-fields': { username: 'admin_user' }, - }), - }, - ); - const data = await res.json(); - - // Response should have user nested (not splatted) and created flag - expect(data.user).toBeDefined(); - expect(data.user.email).toBe(email); - expect(data.created).toBe(true); - - // Second call -- existing user - const code2 = await generateMagicCode(appId, adminToken, email); - const res2 = await fetch( - `${apiUrl}/admin/verify_magic_code?app_id=${appId}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - authorization: `Bearer ${adminToken}`, - }, - body: JSON.stringify({ email, code: code2 }), - }, - ); - const data2 = await res2.json(); - - expect(data2.user).toBeDefined(); - expect(data2.created).toBe(false); - }, -); diff --git a/client/www/pages/docs/http-api.md b/client/www/pages/docs/http-api.md index 334458875d..8e9bf6ed11 100644 --- a/client/www/pages/docs/http-api.md +++ b/client/www/pages/docs/http-api.md @@ -267,7 +267,7 @@ curl -X POST "https://api.instantdb.com/admin/refresh_tokens" \ -d "{\"id\":\"$USER_ID\"}" ``` -If a user with the provider id or email does not exist, Instant will create the +If a user with the given `id` or `email` does not exist, Instant will create the user for you. You can pass `extra-fields` to set custom `$users` properties on creation. The response includes `user.refresh_token` and `"created": true` when a new user is created. You can pass this token onto your client, and use that to [log in](/docs/backend#2-frontend-db-auth-sign-in-with-token) ## Custom magic codes diff --git a/server/src/instant/db/permissioned_transaction.clj b/server/src/instant/db/permissioned_transaction.clj index 5467f846ef..1149562930 100644 --- a/server/src/instant/db/permissioned_transaction.clj +++ b/server/src/instant/db/permissioned_transaction.clj @@ -46,16 +46,6 @@ (throw-tx-step-validation-err! tx-step (format "%s is a system entity. You aren't allowed to delete this directly." etype)))) -(defn- validate-system-create-entity! [{:keys [admin? attrs]} {:keys [aid] :as tx-step}] - (let [attr (attr-model/seek-by-id aid attrs) - [etype label] (attr-model/fwd-ident-name attr)] - (when (and (= "$users" etype) - (= "id" label) - (not admin?)) - (throw-tx-step-validation-err! - tx-step - "$users is a system entity. You aren't allowed to create this directly.")))) - (defn- validate-system-triple-op! [{:keys [admin? attrs]} {:keys [aid] :as tx-step}] (let [attr (attr-model/seek-by-id aid attrs) {:keys [catalog]} attr @@ -117,9 +107,7 @@ :when (#{:add-triple :deep-merge-triple :retract-triple :delete-entity} op)] (if (= :delete-entity op) (validate-system-delete-entity! ctx tx-step) - (do - (validate-system-create-entity! ctx tx-step) - (validate-system-triple-op! ctx tx-step))))) + (validate-system-triple-op! ctx tx-step)))) (defn coerce-value-uuids "Checks that all ref values are either lookup refs or UUIDs" @@ -600,18 +588,23 @@ (and (#{:add-triple :deep-merge-triple} op) create?) - [{:scope :object - :action :create - :etype etype - :eid (get create-lookups-map eid eid) - :program (or (rule-model/get-program! rules etype "create") - {:result true}) - :bindings (let [updated-entity (-> (get updated-entities-map key) - (update "id" #(get create-lookups-map % %)))] - {:data updated-entity - :new-data updated-entity - :rule-params rule-params - :modified-fields (get-modified-fields-for-eid eid tx-steps attrs)})}] + (do + (when (= "$users" etype) + (throw-tx-step-validation-err! + {:op op :eid eid :aid aid :etype etype :value value} + "$users is a system entity. You aren't allowed to create this directly.")) + [{:scope :object + :action :create + :etype etype + :eid (get create-lookups-map eid eid) + :program (or (rule-model/get-program! rules etype "create") + {:result true}) + :bindings (let [updated-entity (-> (get updated-entities-map key) + (update "id" #(get create-lookups-map % %)))] + {:data updated-entity + :new-data updated-entity + :rule-params rule-params + :modified-fields (get-modified-fields-for-eid eid tx-steps attrs)})}]) :else [])] From 5b97942f38ecbf8ee99cf06219d95bedf5800358 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 17:55:45 -0700 Subject: [PATCH 23/34] Move e2e test to separate PR --- .../src/auth-extra-fields.e2e.test.ts | 86 ------------------- 1 file changed, 86 deletions(-) delete mode 100644 client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts diff --git a/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts b/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts deleted file mode 100644 index 2d6b469441..0000000000 --- a/client/packages/core/__tests__/src/auth-extra-fields.e2e.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { expect } from 'vitest'; -import { i } from '../../src'; -import { makeE2ETest, apiUrl } from './utils/e2e'; - -const schema = i.schema({ - entities: { - $users: i.entity({ - email: i.string().unique().indexed().optional(), - username: i.string().unique().indexed().optional(), - displayName: i.string().optional(), - }), - }, -}); - -async function generateMagicCode( - appId: string, - adminToken: string, - email: string, -): Promise { - const res = await fetch(`${apiUrl}/admin/magic_code`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'app-id': appId, - authorization: `Bearer ${adminToken}`, - }, - body: JSON.stringify({ email }), - }); - const data = await res.json(); - return data.code; -} - -const authTest = makeE2ETest({ schema }); - -authTest( - 'new user with extraFields gets fields written and created=true', - async ({ db, appId, adminToken }) => { - const email = `new-${Date.now()}@test.com`; - - const code = await generateMagicCode(appId, adminToken, email); - const res = await db.auth.signInWithMagicCode({ - email, - code, - extraFields: { username: 'cool_user', displayName: 'Cool User' }, - }); - - expect(res.created).toBe(true); - - const { data } = await db.queryOnce({ $users: {} }); - const user = data.$users.find((u: any) => u.email === email); - expect(user).toBeDefined(); - expect(user!.username).toBe('cool_user'); - expect(user!.displayName).toBe('Cool User'); - }, -); - -authTest( - 'returning user gets created=false', - async ({ db, appId, adminToken }) => { - const email = `returning-${Date.now()}@test.com`; - - // First sign in -- creates user - const code1 = await generateMagicCode(appId, adminToken, email); - const res1 = await db.auth.signInWithMagicCode({ email, code: code1 }); - expect(res1.created).toBe(true); - - // Second sign in -- existing user - const code2 = await generateMagicCode(appId, adminToken, email); - const res2 = await db.auth.signInWithMagicCode({ email, code: code2 }); - expect(res2.created).toBe(false); - }, -); - -authTest( - 'sign in without extraFields works (backwards compat)', - async ({ db, appId, adminToken }) => { - const email = `compat-${Date.now()}@test.com`; - - const code = await generateMagicCode(appId, adminToken, email); - const res = await db.auth.signInWithMagicCode({ email, code }); - - expect(res.user).toBeDefined(); - expect(res.user.email).toBe(email); - }, -); - From cfc6d4f03b1738e8e87aabfb28a2a1e5535411b6 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 18:53:20 -0700 Subject: [PATCH 24/34] rm un-nec check --- server/src/instant/model/app_user.clj | 29 +++++++++++++-------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/server/src/instant/model/app_user.clj b/server/src/instant/model/app_user.clj index bc40a32f44..072fe44258 100644 --- a/server/src/instant/model/app_user.clj +++ b/server/src/instant/model/app_user.clj @@ -19,21 +19,20 @@ "Validates that extra-fields keys exist in the $users schema and are not system fields." [app-id extra-fields] - (when (seq extra-fields) - (let [attrs (attr-model/get-by-app-id app-id)] - (doseq [[k _v] extra-fields] - (let [k-str (name k) - attr (attr-model/seek-by-fwd-ident-name [etype k-str] attrs)] - (when-not attr - (ex/throw-validation-err! - :extra-fields - extra-fields - [{:message (format "Unknown field: %s. It must be defined in your $users schema." k-str)}])) - (when (= :system (:catalog attr)) - (ex/throw-validation-err! - :extra-fields - extra-fields - [{:message (format "Cannot set system field: %s" k-str)}]))))))) + (let [attrs (attr-model/get-by-app-id app-id)] + (doseq [[k _v] extra-fields] + (let [k-str (name k) + attr (attr-model/seek-by-fwd-ident-name [etype k-str] attrs)] + (when-not attr + (ex/throw-validation-err! + :extra-fields + extra-fields + [{:message (format "Unknown field: %s. It must be defined in your $users schema." k-str)}])) + (when (= :system (:catalog attr)) + (ex/throw-validation-err! + :extra-fields + extra-fields + [{:message (format "Cannot set system field: %s" k-str)}])))))) (defn assert-create-permission! "Checks the $users create rule against the user data being created. From 9d40826abf1f8d236a2bdb987314bda3b569957f Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 18:53:42 -0700 Subject: [PATCH 25/34] better types --- client/packages/admin/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/packages/admin/src/index.ts b/client/packages/admin/src/index.ts index 4b8a4b15b5..66739ae9db 100644 --- a/client/packages/admin/src/index.ts +++ b/client/packages/admin/src/index.ts @@ -453,7 +453,7 @@ class Rooms> { } } -class Auth { +class Auth> { config: FilledConfig; constructor(config: FilledConfig) { @@ -541,7 +541,7 @@ class Auth { consumeMagicCode = async ( email: string, code: string, - options?: { extraFields?: Record }, + options?: { extraFields?: UpdateParams }, ): Promise<{ user: User; created: boolean }> => { const res = await jsonFetch( `${this.config.apiURI}/admin/verify_magic_code?app_id=${this.config.appId}`, @@ -1100,7 +1100,7 @@ class InstantAdminDatabase< >, > { config: InstantConfigFilled; - auth: Auth; + auth: Auth; storage: Storage; streams: Streams; rooms: Rooms; @@ -1115,7 +1115,7 @@ class InstantAdminDatabase< constructor(_config: Config) { this.config = instantConfigWithDefaults(_config); - this.auth = new Auth(this.config); + this.auth = new Auth(this.config); this.storage = new Storage(this.config, this.impersonationOpts); this.streams = new Streams(this.#ensureInstantStream.bind(this)); this.rooms = new Rooms(this.config); From 18bbed3602facb625703d796a38eef76f44014e0 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 19:14:06 -0700 Subject: [PATCH 26/34] create rule is allowed --- server/test/instant/model/rule_test.clj | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/server/test/instant/model/rule_test.clj b/server/test/instant/model/rule_test.clj index 0faf403438..cc530a72d7 100644 --- a/server/test/instant/model/rule_test.clj +++ b/server/test/instant/model/rule_test.clj @@ -98,15 +98,11 @@ "child" "parent || true"] "allow" {"view" "parent"}}})))) -(deftest can-only-create-view-update-rules-for-users - (is (= [{:message - "The $users namespace doesn't support permissions for create. Set `$users.allow.create` to `\"false\"`.", - :in ["$users" "allow" "create"]}] - (rule/validation-errors {"$users" {"allow" {"create" "true"}}})) - (= [{:message - "The $users namespace doesn't support permissions for delete. Set `$users.allow.delete` to `\"false\"`.", - :in ["$users" "allow" "delete"]}] - (rule/validation-errors {"$users" {"allow" {"delete" "true"}}})))) +(deftest can-set-create-view-update-rules-for-users + (is (empty? (rule/validation-errors {"$users" {"allow" {"create" "true"}}}))) + (is (empty? (rule/validation-errors {"$users" {"allow" {"view" "true"}}}))) + (is (empty? (rule/validation-errors {"$users" {"allow" {"update" "true"}}}))) + (is (seq (rule/validation-errors {"$users" {"allow" {"delete" "true"}}})))) (deftest cant-write-rules-for-system-attrs (is (= [{:message From ce4dc4014d5932b0b1a4a5c761b89d1a0dfda600 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 19 Mar 2026 20:19:45 -0700 Subject: [PATCH 27/34] Add assert-signup --- server/src/instant/admin/routes.clj | 24 +++++++++--------- server/src/instant/model/app_user.clj | 25 +++++++++++++++---- .../src/instant/runtime/magic_code_auth.clj | 20 ++++++--------- server/src/instant/runtime/routes.clj | 18 +++++-------- 4 files changed, 46 insertions(+), 41 deletions(-) diff --git a/server/src/instant/admin/routes.clj b/server/src/instant/admin/routes.clj index 252e512019..b323da88ab 100644 --- a/server/src/instant/admin/routes.clj +++ b/server/src/instant/admin/routes.clj @@ -412,18 +412,18 @@ {user-id :id :as user} (or existing-user - (do - (app-user-model/validate-extra-fields! app-id extra-fields) - (cond - email (app-user-model/create! - {:id (UUID/randomUUID) - :app-id app-id - :email email - :extra-fields extra-fields}) - id (app-user-model/create! - {:id id - :app-id app-id - :extra-fields extra-fields})))) + (let [user-id (or id (UUID/randomUUID))] + (app-user-model/assert-signup! + {:app-id app-id + :email email + :id user-id + :extra-fields extra-fields + :skip-perm-check? true}) + (app-user-model/create! + {:id user-id + :app-id app-id + :email email + :extra-fields extra-fields}))) {refresh-token-id :id} (app-user-refresh-token-model/create! diff --git a/server/src/instant/model/app_user.clj b/server/src/instant/model/app_user.clj index 072fe44258..bfc92235e6 100644 --- a/server/src/instant/model/app_user.clj +++ b/server/src/instant/model/app_user.clj @@ -34,11 +34,16 @@ extra-fields [{:message (format "Cannot set system field: %s" k-str)}])))))) -(defn assert-create-permission! - "Checks the $users create rule against the user data being created. - Used during auth signup flows. If no create rule is set, allows by default. - Both `auth` and `data` are set to the user being created." - [{:keys [app-id user-data]}] +(defn- build-user-data + [{:keys [email id extra-fields]}] + (cond-> {"id" (str id)} + email (assoc "email" email) + extra-fields (merge (into {} + (map (fn [[k v]] [(name k) v])) + extra-fields)))) + +(defn- assert-create-permission! + [app-id user-data] (let [rules (rule-model/get-by-app-id {:app-id app-id}) program (rule-model/get-program! rules "$users" "create")] (when program @@ -53,6 +58,16 @@ (cel/eval-program! ctx program {:data user-data :new-data user-data})))))) +(defn assert-signup! + "Validates extra-fields and checks the $users create permission rule. + Pass skip-perm-check? true to skip the permission check (admin flows)." + [{:keys [app-id extra-fields skip-perm-check?] :as params}] + (validate-extra-fields! app-id extra-fields) + (when-not skip-perm-check? + (let [id (or (:id params) (random-uuid)) + user-data (build-user-data (assoc params :id id))] + (assert-create-permission! app-id user-data)))) + (defn create! ([params] (create! (aurora/conn-pool :write) params)) diff --git a/server/src/instant/runtime/magic_code_auth.clj b/server/src/instant/runtime/magic_code_auth.clj index a3cef652cc..ff2a940725 100644 --- a/server/src/instant/runtime/magic_code_auth.clj +++ b/server/src/instant/runtime/magic_code_auth.clj @@ -139,19 +139,15 @@ :email email}) created? (nil? existing-user) user-id (or guest-user-id (random-uuid))] - ;; Only validate extra-fields and check create permission for new users. - ;; Returning users ignore extra-fields so we skip validation for them. + ;; Check before consuming the code so a failed check doesn't + ;; burn the one-time code. (when created? - (app-user-model/validate-extra-fields! app-id extra-fields) - (when-not admin? - (let [user-data (cond-> {"email" email - "id" (str user-id)} - extra-fields (merge (into {} - (map (fn [[k v]] [(name k) v])) - extra-fields)))] - (app-user-model/assert-create-permission! - {:app-id app-id - :user-data user-data})))) + (app-user-model/assert-signup! + {:app-id app-id + :email email + :id user-id + :extra-fields extra-fields + :skip-perm-check? admin?})) (app-user-magic-code-model/consume! {:app-id app-id :code code diff --git a/server/src/instant/runtime/routes.clj b/server/src/instant/runtime/routes.clj index 6010863f95..8a80cc48b0 100644 --- a/server/src/instant/runtime/routes.clj +++ b/server/src/instant/runtime/routes.clj @@ -130,10 +130,8 @@ (defn sign-in-guest-post [req] (let [app-id (ex/get-param! req [:body :app-id] uuid-util/coerce) user-id (random-uuid) - user-data {"id" (str user-id)} - _ (app-user-model/assert-create-permission! - {:app-id app-id - :user-data user-data}) + _ (app-user-model/assert-signup! + {:app-id app-id :id user-id}) user (app-user-model/create! {:app-id app-id :id user-id @@ -285,15 +283,11 @@ :sub (:app_user_oauth_links/sub oauth-link)})})))] (if-not user - (let [_ (app-user-model/validate-extra-fields! app-id extra-fields) - user-data (cond-> {"email" email - "id" (str (or guest-user-id (random-uuid)))} - extra-fields (merge (into {} - (map (fn [[k v]] [(name k) v])) - extra-fields))) - _ (app-user-model/assert-create-permission! + (let [_ (app-user-model/assert-signup! {:app-id app-id - :user-data user-data}) + :email email + :id (or guest-user-id (random-uuid)) + :extra-fields extra-fields}) new-user (app-user-model/create! {:id guest-user-id :app-id app-id From f4bdbec44a6cc210d6944ab866ffec95de51ab05 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Fri, 20 Mar 2026 13:05:58 -0700 Subject: [PATCH 28/34] consumeMagicCode -> checkMagicCode --- client/packages/admin/src/index.ts | 6 +++--- client/packages/core/src/Reactor.js | 2 +- client/packages/core/src/authAPI.ts | 14 +++++++------- client/packages/core/src/index.ts | 16 ++++++++-------- client/packages/react-native/src/index.ts | 8 ++++---- client/packages/react/src/index.ts | 8 ++++---- client/packages/solidjs/src/index.ts | 8 ++++---- client/packages/svelte/src/lib/index.ts | 8 ++++---- client/www/pages/docs/backend.md | 4 ++-- 9 files changed, 37 insertions(+), 37 deletions(-) diff --git a/client/packages/admin/src/index.ts b/client/packages/admin/src/index.ts index 66739ae9db..6d9cac76ac 100644 --- a/client/packages/admin/src/index.ts +++ b/client/packages/admin/src/index.ts @@ -507,7 +507,7 @@ class Auth> { }; /** - * @deprecated Use {@link consumeMagicCode} instead to get the `created` field + * @deprecated Use {@link checkMagicCode} instead to get the `created` field * and support `extraFields`. * * @see https://instantdb.com/docs/backend#custom-magic-codes @@ -530,7 +530,7 @@ class Auth> { * `$users` properties at signup. * * @example - * const { user, created } = await db.auth.consumeMagicCode( + * const { user, created } = await db.auth.checkMagicCode( * email, * code, * { extraFields: { nickname: 'ari' } }, @@ -538,7 +538,7 @@ class Auth> { * * @see https://instantdb.com/docs/backend#custom-magic-codes */ - consumeMagicCode = async ( + checkMagicCode = async ( email: string, code: string, options?: { extraFields?: UpdateParams }, diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js index ff3b7e86c6..dab6e46526 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -2211,7 +2211,7 @@ export default class Reactor { async signInWithMagicCode(params) { const currentUser = await this.getCurrentUser(); const isGuest = currentUser?.user?.type === 'guest'; - const res = await authAPI.consumeMagicCode({ + const res = await authAPI.checkMagicCode({ apiURI: this.config.apiURI, appId: this.config.appId, email: params.email, diff --git a/client/packages/core/src/authAPI.ts b/client/packages/core/src/authAPI.ts index 7dbc26004d..ab62ea4ab4 100644 --- a/client/packages/core/src/authAPI.ts +++ b/client/packages/core/src/authAPI.ts @@ -33,7 +33,7 @@ export type VerifyResponse = { }; /** - * @deprecated Use {@link consumeMagicCode} instead to get the `created` field + * @deprecated Use {@link checkMagicCode} instead to get the `created` field * and support `extraFields`. */ export async function verifyMagicCode({ @@ -56,24 +56,24 @@ export async function verifyMagicCode({ return res; } -export type ConsumeMagicCodeParams = { +export type CheckMagicCodeParams = { email: string; code: string; refreshToken?: string | undefined; extraFields?: Record | undefined; }; -export type ConsumeMagicCodeResponse = { +export type CheckMagicCodeResponse = { user: User; created: boolean; }; -export async function consumeMagicCode({ +export async function checkMagicCode({ apiURI, appId, email, code, refreshToken, extraFields, -}: SharedInput & ConsumeMagicCodeParams): Promise { +}: SharedInput & CheckMagicCodeParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/auth/verify_magic_code`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -134,7 +134,7 @@ export async function exchangeCodeForToken({ refreshToken, extraFields, }: SharedInput & - ExchangeCodeForTokenParams): Promise { + ExchangeCodeForTokenParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -165,7 +165,7 @@ export async function signInWithIdToken({ clientName, refreshToken, extraFields, -}: SharedInput & SignInWithIdTokenParams): Promise { +}: SharedInput & SignInWithIdTokenParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/oauth/id_token`, { method: 'POST', headers: { 'content-type': 'application/json' }, diff --git a/client/packages/core/src/index.ts b/client/packages/core/src/index.ts index 06ec81c088..dc81260a2f 100644 --- a/client/packages/core/src/index.ts +++ b/client/packages/core/src/index.ts @@ -114,8 +114,8 @@ import type { UploadFileResponse, DeleteFileResponse } from './StorageAPI.ts'; import { FrameworkClient, type FrameworkConfig } from './framework.ts'; import type { - ConsumeMagicCodeParams, - ConsumeMagicCodeResponse, + CheckMagicCodeParams, + CheckMagicCodeResponse, ExchangeCodeForTokenParams, SendMagicCodeParams, SendMagicCodeResponse, @@ -349,8 +349,8 @@ class Auth { * .catch((err) => console.error(err.body?.message)) */ signInWithMagicCode = ( - params: ConsumeMagicCodeParams, - ): Promise => { + params: CheckMagicCodeParams, + ): Promise => { return this.db.signInWithMagicCode(params); }; @@ -424,7 +424,7 @@ class Auth { */ signInWithIdToken = ( params: SignInWithIdTokenParams, - ): Promise => { + ): Promise => { return this.db.signInWithIdToken(params); }; @@ -446,7 +446,7 @@ class Auth { */ exchangeOAuthCode = ( params: ExchangeCodeForTokenParams, - ): Promise => { + ): Promise => { return this.db.exchangeCodeForToken(params); }; @@ -1160,8 +1160,8 @@ export { type InstantDBInferredType, // auth types - type ConsumeMagicCodeParams, - type ConsumeMagicCodeResponse, + type CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, diff --git a/client/packages/react-native/src/index.ts b/client/packages/react-native/src/index.ts index 2b44297a30..1a59654db9 100644 --- a/client/packages/react-native/src/index.ts +++ b/client/packages/react-native/src/index.ts @@ -74,8 +74,8 @@ import { type UpdateParams, type LinkParams, type ValidQuery, - type ConsumeMagicCodeParams, - type ConsumeMagicCodeResponse, + type CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -249,8 +249,8 @@ export { type UpdateParams, type LinkParams, type ValidQuery, - type ConsumeMagicCodeParams, - type ConsumeMagicCodeResponse, + type CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type TransactionChunk, diff --git a/client/packages/react/src/index.ts b/client/packages/react/src/index.ts index 9e8a72e5c5..ed0c08c584 100644 --- a/client/packages/react/src/index.ts +++ b/client/packages/react/src/index.ts @@ -64,8 +64,8 @@ import { type UpdateParams, type LinkParams, type CreateParams, - type ConsumeMagicCodeParams, - type ConsumeMagicCodeResponse, + type CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -176,8 +176,8 @@ export { type UpdateParams, type LinkParams, type CreateParams, - type ConsumeMagicCodeParams, - type ConsumeMagicCodeResponse, + type CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, diff --git a/client/packages/solidjs/src/index.ts b/client/packages/solidjs/src/index.ts index fdb2628935..69beef09d8 100644 --- a/client/packages/solidjs/src/index.ts +++ b/client/packages/solidjs/src/index.ts @@ -63,8 +63,8 @@ import { type UpdateParams, type LinkParams, type CreateParams, - type ConsumeMagicCodeParams, - type ConsumeMagicCodeResponse, + type CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -162,8 +162,8 @@ export { type UpdateParams, type LinkParams, type CreateParams, - type ConsumeMagicCodeParams, - type ConsumeMagicCodeResponse, + type CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, diff --git a/client/packages/svelte/src/lib/index.ts b/client/packages/svelte/src/lib/index.ts index 82affa32d6..92b32bb239 100644 --- a/client/packages/svelte/src/lib/index.ts +++ b/client/packages/svelte/src/lib/index.ts @@ -63,8 +63,8 @@ import { type UpdateParams, type LinkParams, type CreateParams, - type ConsumeMagicCodeParams, - type ConsumeMagicCodeResponse, + type CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -168,8 +168,8 @@ export { type UpdateParams, type LinkParams, type CreateParams, - type ConsumeMagicCodeParams, - type ConsumeMagicCodeResponse, + type CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, diff --git a/client/www/pages/docs/backend.md b/client/www/pages/docs/backend.md index 15cba1b3fc..88ed7c2a3a 100644 --- a/client/www/pages/docs/backend.md +++ b/client/www/pages/docs/backend.md @@ -397,10 +397,10 @@ You can also use Instant's default email provider to send a magic code with `db. const { code } = await db.auth.sendMagicCode(req.body.email); ``` -Similarly, you can verify a magic code with `db.auth.consumeMagicCode`. You can pass `extraFields` to set custom `$users` properties when the user is first created. The response includes a `created` boolean so you can distinguish new users from returning ones. +Similarly, you can verify a magic code with `db.auth.checkMagicCode`. You can pass `extraFields` to set custom `$users` properties when the user is first created. The response includes a `created` boolean so you can distinguish new users from returning ones. ```typescript {% showCopy=true %} -const { user, created } = await db.auth.consumeMagicCode( +const { user, created } = await db.auth.checkMagicCode( req.body.email, req.body.code, { extraFields: { nickname: req.body.nickname } }, From 3cb6d4b79fa28ee97535f484bb352640aa15cd2c Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Fri, 20 Mar 2026 13:24:46 -0700 Subject: [PATCH 29/34] lint --- client/packages/core/src/authAPI.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/packages/core/src/authAPI.ts b/client/packages/core/src/authAPI.ts index ab62ea4ab4..ae72b08c80 100644 --- a/client/packages/core/src/authAPI.ts +++ b/client/packages/core/src/authAPI.ts @@ -133,8 +133,7 @@ export async function exchangeCodeForToken({ codeVerifier, refreshToken, extraFields, -}: SharedInput & - ExchangeCodeForTokenParams): Promise { +}: SharedInput & ExchangeCodeForTokenParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/json' }, From d949a22f96ba8f7f337e61ade70388d42412325d Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Fri, 20 Mar 2026 13:47:51 -0700 Subject: [PATCH 30/34] Use perm pass --- server/src/instant/model/app_user.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/instant/model/app_user.clj b/server/src/instant/model/app_user.clj index bfc92235e6..1f1380b604 100644 --- a/server/src/instant/model/app_user.clj +++ b/server/src/instant/model/app_user.clj @@ -53,7 +53,7 @@ :datalog-query-fn d/query :current-user user-data}] (ex/assert-permitted! - :has-signup-permission? + :perms-pass? ["$users" "create"] (cel/eval-program! ctx program {:data user-data :new-data user-data})))))) From 626b4f1b58b71650c86d67f07760dba8ed16d5ba Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Fri, 20 Mar 2026 13:58:06 -0700 Subject: [PATCH 31/34] Store OAuth extraFields per nonce instead of single shared key --- client/packages/core/src/Reactor.js | 41 +++++++++++++++++++---------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js index dab6e46526..0df794d736 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -63,8 +63,9 @@ const defaultConfig = { // Param that the backend adds if this is an oauth redirect const OAUTH_REDIRECT_PARAM = '_instant_oauth_redirect'; +const OAUTH_NONCE_PARAM = '_instant_oauth_nonce'; -const oauthExtraFieldsKey = 'oauthExtraFields'; +const oauthExtraFieldsPrefix = 'oauthExtraFields_'; const currentUserKey = `currentUser`; @@ -1879,6 +1880,7 @@ export default class Reactor { if (url.searchParams.get(OAUTH_REDIRECT_PARAM)) { const startUrl = url.toString(); url.searchParams.delete(OAUTH_REDIRECT_PARAM); + url.searchParams.delete(OAUTH_NONCE_PARAM); url.searchParams.delete('code'); url.searchParams.delete('error'); const newPath = @@ -1951,13 +1953,18 @@ export default class Reactor { if (!code) { return null; } + const nonce = params.get(OAUTH_NONCE_PARAM); this._replaceUrlAfterOAuth(); try { - const extraFields = await this.kv.waitForKeyToLoad(oauthExtraFieldsKey); - if (extraFields) { - this.kv.updateInPlace((prev) => { - delete prev[oauthExtraFieldsKey]; - }); + let extraFields; + if (nonce) { + const key = `${oauthExtraFieldsPrefix}${nonce}`; + extraFields = await this.kv.waitForKeyToLoad(key); + if (extraFields) { + this.kv.updateInPlace((prev) => { + delete prev[key]; + }); + } } const currentUser = await this._getCurrentUser(); const isGuest = currentUser?.type === 'guest'; @@ -2280,15 +2287,21 @@ export default class Reactor { * @returns {string} The created authorization URL. */ createAuthorizationURL({ clientName, redirectURL, extraFields }) { - this.kv.updateInPlace((prev) => { - if (extraFields) { - prev[oauthExtraFieldsKey] = extraFields; - } else { - delete prev[oauthExtraFieldsKey]; - } - }); const { apiURI, appId } = this.config; - return `${apiURI}/runtime/oauth/start?app_id=${appId}&client_name=${clientName}&redirect_uri=${redirectURL}`; + let finalRedirectURL = redirectURL; + if (extraFields) { + // Store extraFields under a unique nonce so multiple + // createAuthorizationURL calls don't overwrite each other. + // The nonce is passed through the redirect URL and used + // by _oauthLoginInit to retrieve the right extraFields. + const nonce = `${appId}_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const key = `${oauthExtraFieldsPrefix}${nonce}`; + this.kv.updateInPlace((prev) => { + prev[key] = extraFields; + }); + finalRedirectURL = `${redirectURL}${redirectURL.includes('?') ? '&' : '?'}${OAUTH_NONCE_PARAM}=${nonce}`; + } + return `${apiURI}/runtime/oauth/start?app_id=${appId}&client_name=${clientName}&redirect_uri=${encodeURIComponent(finalRedirectURL)}`; } /** From ed2f8fd9d26e96398038b8faa1b747b12c58ea45 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Fri, 20 Mar 2026 14:34:14 -0700 Subject: [PATCH 32/34] sm test --- server/test/instant/runtime/routes_test.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/test/instant/runtime/routes_test.clj b/server/test/instant/runtime/routes_test.clj index 63c80acb18..a10aff13c0 100644 --- a/server/test/instant/runtime/routes_test.clj +++ b/server/test/instant/runtime/routes_test.clj @@ -665,7 +665,7 @@ (rule-model/put! {:app-id app-id :code {"$users" {"allow" {"create" "false"}}}}) (is (thrown-with-msg? - ExceptionInfo #"permission" + ExceptionInfo #"Permission denied" (route/upsert-oauth-link! {:email "oauth-blocked@test.com" :sub "oauth-sub-blocked" :app-id app-id From a6214f9dfa14bccf57d43b8f8ae7c2e349a82b5d Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Fri, 20 Mar 2026 15:12:05 -0700 Subject: [PATCH 33/34] sm --- client/packages/core/src/Reactor.js | 39 ++++++++++++++++------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js index 0df794d736..344ca97044 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -63,9 +63,9 @@ const defaultConfig = { // Param that the backend adds if this is an oauth redirect const OAUTH_REDIRECT_PARAM = '_instant_oauth_redirect'; -const OAUTH_NONCE_PARAM = '_instant_oauth_nonce'; +const OAUTH_EXTRA_FIELDS_ID_PARAM = '_instant_extra_fields_id'; -const oauthExtraFieldsPrefix = 'oauthExtraFields_'; +const oauthExtraFieldsKey = 'oauthExtraFields'; const currentUserKey = `currentUser`; @@ -1880,7 +1880,7 @@ export default class Reactor { if (url.searchParams.get(OAUTH_REDIRECT_PARAM)) { const startUrl = url.toString(); url.searchParams.delete(OAUTH_REDIRECT_PARAM); - url.searchParams.delete(OAUTH_NONCE_PARAM); + url.searchParams.delete(OAUTH_EXTRA_FIELDS_ID_PARAM); url.searchParams.delete('code'); url.searchParams.delete('error'); const newPath = @@ -1953,18 +1953,19 @@ export default class Reactor { if (!code) { return null; } - const nonce = params.get(OAUTH_NONCE_PARAM); + const extraFieldsId = params.get(OAUTH_EXTRA_FIELDS_ID_PARAM); this._replaceUrlAfterOAuth(); try { let extraFields; - if (nonce) { - const key = `${oauthExtraFieldsPrefix}${nonce}`; - extraFields = await this.kv.waitForKeyToLoad(key); - if (extraFields) { - this.kv.updateInPlace((prev) => { - delete prev[key]; - }); - } + const stored = await this.kv.waitForKeyToLoad(oauthExtraFieldsKey); + if (extraFieldsId && stored) { + extraFields = stored[extraFieldsId]; + } + // Clean up all stored extraFields after login + if (stored) { + this.kv.updateInPlace((prev) => { + delete prev[oauthExtraFieldsKey]; + }); } const currentUser = await this._getCurrentUser(); const isGuest = currentUser?.type === 'guest'; @@ -2290,16 +2291,18 @@ export default class Reactor { const { apiURI, appId } = this.config; let finalRedirectURL = redirectURL; if (extraFields) { - // Store extraFields under a unique nonce so multiple + // Store extraFields under a unique ID so multiple // createAuthorizationURL calls don't overwrite each other. - // The nonce is passed through the redirect URL and used + // The ID is passed through the redirect URL and used // by _oauthLoginInit to retrieve the right extraFields. - const nonce = `${appId}_${Date.now()}_${Math.random().toString(36).slice(2)}`; - const key = `${oauthExtraFieldsPrefix}${nonce}`; + // All entries are cleaned up after login. + const extraFieldsId = `${Date.now()}_${Math.random().toString(36).slice(2)}`; this.kv.updateInPlace((prev) => { - prev[key] = extraFields; + const stored = prev[oauthExtraFieldsKey] || {}; + stored[extraFieldsId] = extraFields; + prev[oauthExtraFieldsKey] = stored; }); - finalRedirectURL = `${redirectURL}${redirectURL.includes('?') ? '&' : '?'}${OAUTH_NONCE_PARAM}=${nonce}`; + finalRedirectURL = `${redirectURL}${redirectURL.includes('?') ? '&' : '?'}${OAUTH_EXTRA_FIELDS_ID_PARAM}=${extraFieldsId}`; } return `${apiURI}/runtime/oauth/start?app_id=${appId}&client_name=${clientName}&redirect_uri=${encodeURIComponent(finalRedirectURL)}`; } From bc1844381777d75e726132c513c923bf7ef9b6b1 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Fri, 20 Mar 2026 15:18:39 -0700 Subject: [PATCH 34/34] rm date --- client/packages/core/src/Reactor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js index 344ca97044..c25158c40e 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -2296,7 +2296,7 @@ export default class Reactor { // The ID is passed through the redirect URL and used // by _oauthLoginInit to retrieve the right extraFields. // All entries are cleaned up after login. - const extraFieldsId = `${Date.now()}_${Math.random().toString(36).slice(2)}`; + const extraFieldsId = `${Math.random().toString(36).slice(2)}`; this.kv.updateInPlace((prev) => { const stored = prev[oauthExtraFieldsKey] || {}; stored[extraFieldsId] = extraFields;