diff --git a/client/packages/admin/src/index.ts b/client/packages/admin/src/index.ts index 89ef9f2db6..6d9cac76ac 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) { @@ -507,11 +507,8 @@ class Auth { }; /** - * Verifies a magic code for the user with the given email. - * - * @example - * const user = await db.auth.verifyMagicCode({ email, code }) - * console.log("Verified user:", user) + * @deprecated Use {@link checkMagicCode} instead to get the `created` field + * and support `extraFields`. * * @see https://instantdb.com/docs/backend#custom-magic-codes */ @@ -527,6 +524,42 @@ class Auth { 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, created } = await db.auth.checkMagicCode( + * email, + * code, + * { extraFields: { nickname: 'ari' } }, + * ); + * + * @see https://instantdb.com/docs/backend#custom-magic-codes + */ + checkMagicCode = async ( + email: string, + code: string, + options?: { extraFields?: UpdateParams }, + ): Promise<{ user: User; created: boolean }> => { + 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, + ...(options?.extraFields + ? { 'extra-fields': options.extraFields } + : {}), + }), + }, + ); + return { user: res.user, created: res.created }; + }; + /** * Creates a login token for the user with the given email. * If that user does not exist, we create one. @@ -1067,7 +1100,7 @@ class InstantAdminDatabase< >, > { config: InstantConfigFilled; - auth: Auth; + auth: Auth; storage: Storage; streams: Streams; rooms: Rooms; @@ -1082,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); diff --git a/client/packages/core/__tests__/src/utils/e2e.ts b/client/packages/core/__tests__/src/utils/e2e.ts index 668a1c173d..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>({ @@ -31,6 +32,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 +52,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({}); 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/src/Reactor.js b/client/packages/core/src/Reactor.js index 49a3cbc70f..c25158c40e 100644 --- a/client/packages/core/src/Reactor.js +++ b/client/packages/core/src/Reactor.js @@ -63,6 +63,9 @@ const defaultConfig = { // Param that the backend adds if this is an oauth redirect const OAUTH_REDIRECT_PARAM = '_instant_oauth_redirect'; +const OAUTH_EXTRA_FIELDS_ID_PARAM = '_instant_extra_fields_id'; + +const oauthExtraFieldsKey = 'oauthExtraFields'; const currentUserKey = `currentUser`; @@ -1877,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_EXTRA_FIELDS_ID_PARAM); url.searchParams.delete('code'); url.searchParams.delete('error'); const newPath = @@ -1949,8 +1953,20 @@ export default class Reactor { if (!code) { return null; } + const extraFieldsId = params.get(OAUTH_EXTRA_FIELDS_ID_PARAM); this._replaceUrlAfterOAuth(); try { + let extraFields; + 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'; const { user } = await authAPI.exchangeCodeForToken({ @@ -1958,6 +1974,7 @@ export default class Reactor { appId: this.config.appId, code, refreshToken: isGuest ? currentUser.refresh_token : undefined, + extraFields, }); this.setCurrentUser(user); return null; @@ -2199,15 +2216,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({ + const res = await authAPI.checkMagicCode({ 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,19 +2284,36 @@ 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 }) { 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 ID so multiple + // createAuthorizationURL calls don't overwrite each other. + // 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 = `${Math.random().toString(36).slice(2)}`; + this.kv.updateInPlace((prev) => { + const stored = prev[oauthExtraFieldsKey] || {}; + stored[extraFieldsId] = extraFields; + prev[oauthExtraFieldsKey] = stored; + }); + 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)}`; } /** * @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 +2322,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 +2338,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..ae72b08c80 100644 --- a/client/packages/core/src/authAPI.ts +++ b/client/packages/core/src/authAPI.ts @@ -31,6 +31,11 @@ export type VerifyMagicCodeParams = { export type VerifyResponse = { user: User; }; + +/** + * @deprecated Use {@link checkMagicCode} instead to get the `created` field + * and support `extraFields`. + */ export async function verifyMagicCode({ apiURI, appId, @@ -51,6 +56,38 @@ export async function verifyMagicCode({ return res; } +export type CheckMagicCodeParams = { + email: string; + code: string; + refreshToken?: string | undefined; + extraFields?: Record | undefined; +}; +export type CheckMagicCodeResponse = { + user: User; + created: boolean; +}; +export async function checkMagicCode({ + apiURI, + appId, + email, + code, + refreshToken, + extraFields, +}: SharedInput & CheckMagicCodeParams): 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 } : {}), + ...(extraFields ? { 'extra-fields': extraFields } : {}), + }), + }); + return res; +} + export type VerifyRefreshTokenParams = { refreshToken: string }; export async function verifyRefreshToken({ apiURI, @@ -86,6 +123,7 @@ export type ExchangeCodeForTokenParams = { code: string; codeVerifier?: string; refreshToken?: string | undefined; + extraFields?: Record | undefined; }; export async function exchangeCodeForToken({ @@ -94,7 +132,8 @@ export async function exchangeCodeForToken({ code, codeVerifier, refreshToken, -}: SharedInput & ExchangeCodeForTokenParams): Promise { + extraFields, +}: SharedInput & ExchangeCodeForTokenParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -103,6 +142,7 @@ export async function exchangeCodeForToken({ code: code, code_verifier: codeVerifier, refresh_token: refreshToken, + ...(extraFields ? { extra_fields: extraFields } : {}), }), }); return res; @@ -113,6 +153,7 @@ export type SignInWithIdTokenParams = { idToken: string; clientName: string; refreshToken?: string; + extraFields?: Record | undefined; }; export async function signInWithIdToken({ @@ -122,7 +163,8 @@ export async function signInWithIdToken({ idToken, clientName, refreshToken, -}: SharedInput & SignInWithIdTokenParams): Promise { + extraFields, +}: SharedInput & SignInWithIdTokenParams): Promise { const res = await jsonFetch(`${apiURI}/runtime/oauth/id_token`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -132,6 +174,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..dc81260a2f 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 { + CheckMagicCodeParams, + CheckMagicCodeResponse, ExchangeCodeForTokenParams, SendMagicCodeParams, SendMagicCodeResponse, @@ -347,8 +349,8 @@ class Auth { * .catch((err) => console.error(err.body?.message)) */ signInWithMagicCode = ( - params: VerifyMagicCodeParams, - ): Promise => { + params: CheckMagicCodeParams, + ): Promise => { return this.db.signInWithMagicCode(params); }; @@ -397,6 +399,7 @@ class Auth { createAuthorizationURL = (params: { clientName: string; redirectURL: string; + extraFields?: Record; }): string => { return this.db.createAuthorizationURL(params); }; @@ -421,7 +424,7 @@ class Auth { */ signInWithIdToken = ( params: SignInWithIdTokenParams, - ): Promise => { + ): Promise => { return this.db.signInWithIdToken(params); }; @@ -441,7 +444,9 @@ class Auth { * .catch((err) => console.error(err.body?.message)); * */ - exchangeOAuthCode = (params: ExchangeCodeForTokenParams) => { + exchangeOAuthCode = ( + params: ExchangeCodeForTokenParams, + ): Promise => { return this.db.exchangeCodeForToken(params); }; @@ -1155,6 +1160,8 @@ export { type InstantDBInferredType, // auth types + type CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, 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'], diff --git a/client/packages/create-instant-app/template/rules/AGENTS.md b/client/packages/create-instant-app/template/rules/AGENTS.md index e52210fed0..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 @@ -277,6 +279,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([ + db.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..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 @@ -283,6 +285,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([ + db.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..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 @@ -283,6 +285,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([ + db.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/react-native/src/index.ts b/client/packages/react-native/src/index.ts index 0933afb1f4..1a59654db9 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 CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -247,6 +249,8 @@ export { type UpdateParams, type LinkParams, type ValidQuery, + 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 b1cbdac022..ed0c08c584 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 CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -174,6 +176,8 @@ export { type UpdateParams, type LinkParams, type CreateParams, + 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 4c64e940f5..69beef09d8 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 CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -160,6 +162,8 @@ export { type UpdateParams, type LinkParams, type CreateParams, + 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 fb96a79385..92b32bb239 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 CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, @@ -166,6 +168,8 @@ export { type UpdateParams, type LinkParams, type CreateParams, + type CheckMagicCodeParams, + type CheckMagicCodeResponse, type ExchangeCodeForTokenParams, type SendMagicCodeParams, type SendMagicCodeResponse, 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..488e6739ed --- /dev/null +++ b/client/sandbox/react-nextjs/pages/play/oauth-extra-fields-google.tsx @@ -0,0 +1,190 @@ +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 = process.env.NEXT_PUBLIC_INSTANT_APP_ID; +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 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() { + 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 }) => { + if (!credential) return; + 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; 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/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 ; +} diff --git a/client/www/lib/intern/instant-rules.md b/client/www/lib/intern/instant-rules.md index e52210fed0..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 @@ -277,6 +279,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([ + db.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..88ed7c2a3a 100644 --- a/client/www/pages/docs/backend.md +++ b/client/www/pages/docs/backend.md @@ -397,11 +397,19 @@ 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.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 = await db.auth.verifyMagicCode(req.body.email, req.body.code); +const { user, created } = await db.auth.checkMagicCode( + req.body.email, + req.body.code, + { extraFields: { nickname: req.body.nickname } }, +); const token = user.refresh_token; + +if (created) { + // first-time signup +} ``` ## Authenticated Endpoints diff --git a/client/www/pages/docs/http-api.md b/client/www/pages/docs/http-api.md index d5fe9b57a1..8e9bf6ed11 100644 --- a/client/www/pages/docs/http-api.md +++ b/client/www/pages/docs/http-api.md @@ -267,8 +267,8 @@ 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 -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) +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 @@ -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..636c44a1db 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 @@ -176,6 +176,118 @@ 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: 'nezaj' }, +}); +``` + +**OAuth with ID token** (Google Button, Apple, Clerk, Firebase) + +```javascript +db.auth.signInWithIdToken({ + clientName: 'google', + idToken, + nonce, + extraFields: { nickname: 'nezaj' }, +}); +``` + +**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: 'nezaj' }, +}); +``` + +**OAuth with code exchange** (Expo, React Native) + +```javascript +db.auth.exchangeOAuthCode({ + code: res.params.code, + codeVerifier: request.codeVerifier, + extraFields: { nickname: 'nezaj' }, +}); +``` + +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([ + db.tx.settings[id()] + .update({ theme: 'light', notifications: true }) + .link({ user: user.id }), + ]); +} +``` + +## 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 diff --git a/server/src/instant/admin/routes.clj b/server/src/instant/admin/routes.clj index 813d9fedb5..b323da88ab 100644 --- a/server/src/instant/admin/routes.clj +++ b/server/src/instant/admin/routes.clj @@ -397,32 +397,42 @@ (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]) - {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 + (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! {: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 +513,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]) 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 + :admin? true})] (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/db/permissioned_transaction.clj b/server/src/instant/db/permissioned_transaction.clj index bfa7b6a1b0..1149562930 100644 --- a/server/src/instant/db/permissioned_transaction.clj +++ b/server/src/instant/db/permissioned_transaction.clj @@ -588,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 [])] diff --git a/server/src/instant/model/app_user.clj b/server/src/instant/model/app_user.clj index d617a24ef1..1f1380b604 100644 --- a/server/src/instant/model/app_user.clj +++ b/server/src/instant/model/app_user.clj @@ -1,9 +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 @@ -11,10 +15,63 @@ (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] + (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- 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 + (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! + :perms-pass? + ["$users" "create"] + (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)) - ([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 +86,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/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 4cda33e83e..ff2a940725 100644 --- a/server/src/instant/runtime/magic_code_auth.clj +++ b/server/src/instant/runtime/magic_code_auth.clj @@ -129,32 +129,48 @@ "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]}] - (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}) - (app-user-model/create! - {:id (or guest-user-id (random-uuid)) - :app-id app-id - :email email - :type "user"})) - 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))) + 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-id (or guest-user-id (random-uuid))] + ;; Check before consuming the code so a failed check doesn't + ;; burn the one-time code. + (when created? + (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 + :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 a7b6ea068a..8a80cc48b0 100644 --- a/server/src/instant/runtime/routes.clj +++ b/server/src/instant/runtime/routes.clj @@ -91,20 +91,23 @@ (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]) + 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 @@ -126,13 +129,13 @@ (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) + _ (app-user-model/assert-signup! + {:app-id app-id :id user-id}) 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 @@ -252,7 +255,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 +283,24 @@ :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 [_ (app-user-model/assert-signup! + {:app-id app-id + :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 + :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 +321,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 +587,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}) @@ -599,12 +612,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 +635,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 +643,7 @@ 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]) 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 +684,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 +696,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/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 diff --git a/server/test/instant/runtime/routes_test.clj b/server/test/instant/runtime/routes_test.clj index c78a23d386..a10aff13c0 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] @@ -10,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] @@ -376,3 +380,341 @@ (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"}}))))) + + ;; 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 + [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}] + (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)))))))) + +;; ----- +;; $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 400" + (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 400" + (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 validate extra-fields values" + (rule-model/put! {:app-id app-id + :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 400" + (verify-body app {:email "nofield@test.com" + :code code + :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 + :extra-fields {"username" "valid_user"}})] + (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}] + (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 denied" + (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 400" + (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 #"system entity" + (permissioned-tx/transact! + ctx + [[:add-triple user-id (:id id-attr) user-id]])))))))) +