diff --git a/packages/auth0/README.md b/packages/auth0/README.md index f89cfb90..6dba27e8 100644 --- a/packages/auth0/README.md +++ b/packages/auth0/README.md @@ -1,6 +1,7 @@ # Auth0 simulator -Read about this simulator on our blog: [Simplified Local Development and Testing with Auth0 Simulation](https://frontside.com/blog/2022-01-13-auth0-simulator/). +Read about this simulator on our blog: [Simplified Local Development and Testing with Auth0 +Simulation](https://frontside.com/blog/2022-01-13-auth0-simulator/). ## Table of Contents @@ -11,22 +12,33 @@ Read about this simulator on our blog: [Simplified Local Development and Testing - [Code](#code) - [Example](#example) - [Configuration](#configuration) + - [CLI Flags](#cli-flags) + - [JSON Config Files](#json-config-files) + - [Environment Variables](#environment-variables) + - [Programmatic Options](#programmatic-options) - [Options](#options) - [Rules](#rules) - [Endpoints](#endpoints) Please read the [main README](../../README.md) for more background on simulacrum. -The auth0 simulator has been initially written to mimic the responses of a real auth0 server that is called from auth0 client libraries like [auth0/react](https://auth0.com/docs/quickstart/spa/react/01-login) and [auth0-spa-js](https://github.com/auth0/auth0-spa-js) that use the OpenID [authorization code flow](https://developer.okta.com/docs/concepts/oauth-openid/). +The auth0 simulator has been initially written to mimic the responses of a real auth0 server that is +called from auth0 client libraries like +[auth0/react](https://auth0.com/docs/quickstart/spa/react/01-login) and +[auth0-spa-js](https://github.com/auth0/auth0-spa-js) that use the OpenID [authorization code +flow](https://developer.okta.com/docs/concepts/oauth-openid/). -If this does not meet your needs then please create a github issue to start a conversation about adding new OpenID flows. +If this does not meet your needs then please create a github issue to start a conversation about +adding new OpenID flows. ## Quick Start This quick start assumes you have your own app with Auth0. -> [!IMPORTANT] -> The Auth0 clients expect the server to be served as `https`, and will throw an error if it is served as `http`. Currently, we rely on a certificate available in the home directory. On first run, you will see instructions on how to set up this certificate through `mkcert`. +> [!IMPORTANT] +> The Auth0 clients expect the server to be served as `https`, and will throw an error if it is +> served as `http`. Currently, we rely on a certificate available in the home directory. On first +> run, you will see instructions on how to set up this certificate through `mkcert`. ### Using Default User @@ -36,12 +48,13 @@ You may start a server directly from the command line. npx @simulacrum/auth0-simulator # this will start a simulation server at http://localhost:4400 ``` -Given no further input, it will use the default values as below. This will point your app at the simulation instead of the Auth0 endpoint. +Given no further input, it will use the default values as below. This will point your app at the +simulation instead of the Auth0 endpoint. ```json { "domain": "https://localhost:4400", - "clientId": "00000000000000000000000000000000", + "clientID": "00000000000000000000000000000000", "audience": "https://thefrontside.auth0.com/api/v1/" } ``` @@ -63,29 +76,102 @@ By passing an `initialState`, you may control the initial users in the store. ### Example -The folks at Auth0 maintain many samples such as [github.com/auth0-samples/auth0-react-samples](https://github.com/auth0-samples/auth0-react-samples). Follow the instructions to run the sample, set the configuration in `auth_config.json` to match the defaults as noted above, and run the Auth0 simulation server with `npx auth0-simulator`. +The folks at Auth0 maintain many samples such as +[github.com/auth0-samples/auth0-react-samples](https://github.com/auth0-samples/auth0-react-samples). +Follow the instructions to run the sample, set the configuration in `auth_config.json` to match the +defaults as noted above, and run the Auth0 simulation server with `npx auth0-simulator`. ## Configuration -The Auth0 Simulator uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig) to load the configuration options. This provides many options in where to place your configuration. Using the module name, `auth0Simulator`, you could, for example, set your configuration in a `.auth0Simulatorrc.json` file. +The Auth0 Simulator uses [configliere](https://github.com/thefrontside/configliere) to parse +configuration from CLI flags, environment variables, JSON config files, and programmatic options. -### Options +### CLI Flags + +When running from the command line, you can pass configuration as flags: + +```bash +npx @simulacrum/auth0-simulator --port 5000 --audience https://myapp.com/api +``` + +Run with `--help` to see all available flags: + +```bash +npx @simulacrum/auth0-simulator --help +``` + +### JSON Config Files + +You can stage configuration through a JSON file with `-c` and then override values with CLI flags: + +```bash +npx @simulacrum/auth0-simulator -c auth0-simulator.json --port 5000 +``` -The `options` field supports the [auth0 configuration fields](https://auth0.com/docs/quickstart/spa/vanillajs#configure-auth0). The option fields should match the fields in the client application that is calling the auth0 server. +Values from CLI flags still take precedence over environment variables and config file values. -The `scope` also accepts an array of objects containing `clientId`, `scope` and optionally `audience` to enable dynamic scopes from a single simulator. This should allow multiple clients to all use the same simulator. Additionally, setting the `clientId: "default"` will enable a default fallback scope so every client does not need to be included. +### Environment Variables + +Configuration can also be set via environment variables. Field names are mapped to +`UPPER_SNAKE_CASE`: + +```bash +PORT=5000 AUDIENCE=https://myapp.com/api npx @simulacrum/auth0-simulator +``` + +### Programmatic Options + +When using the simulator as a library, pass options directly: + +```js +import { simulation } from "@simulacrum/auth0-simulator"; + +const app = simulation({ + options: { + port: 5000, + audience: "https://myapp.com/api", + }, +}); +``` + +### Options -An optional [`rulesDirectory` field](#rules) can specify a directory of [auth0 rules](https://auth0.com/docs/rules) code files, more on this [below](#rules). +The `options` field supports the [auth0 configuration +fields](https://auth0.com/docs/quickstart/spa/vanillajs#configure-auth0). The option fields should +match the fields in the client application that is calling the auth0 server. + +| Option | CLI Flag | Env Var | Description | +| ---------------- | ------------------- | ----------------- | -------------------------------- | +| `port` | `--port`, `-p` | `PORT` | Port to listen on | +| `domain` | `--domain` | `DOMAIN` | Server domain | +| `audience` | `--audience` | `AUDIENCE` | Auth0 audience | +| `clientID` | `--client-id` | `CLIENT_ID` | Auth0 client ID | +| `scope` | `--scope` | `SCOPE` | Auth0 scope | +| `clientSecret` | `--client-secret` | `CLIENT_SECRET` | Client secret | +| `rulesDirectory` | `--rules-directory` | `RULES_DIRECTORY` | Directory containing auth0 rules | +| `connection` | `--connection` | `CONNECTION` | Auth0 connection | +| `protocol` | `--protocol` | `PROTOCOL` | Server protocol (https/http) | + +The `scope` also accepts an array of objects containing `clientID`, `scope` and optionally +`audience` to enable dynamic scopes from a single simulator (programmatic usage only). This should +allow multiple clients to all use the same simulator. Additionally, setting the `clientID: +"default"` will enable a default fallback scope so every client does not need to be included. + +An optional [`rulesDirectory` field](#rules) can specify a directory of [auth0 +rules](https://auth0.com/docs/rules) code files, more on this [below](#rules). ### Rules -It is possible to run [auth0 rules](https://auth0.com/docs/rules) if the compiled code files are on disk and all located in the same directory. +It is possible to run [auth0 rules](https://auth0.com/docs/rules) if the compiled code files are on +disk and all located in the same directory. -Set the `rulesDirectory` of the [options field](#options) to a path relative to your current working directory. +Set the `rulesDirectory` of the [options field](#options) to a path relative to your current working +directory. For example, a [sample rules directory](./test/rules) is in the auth0 package for testing. -If we want to run these rules files then we would add the `rulesDirectory` field to the [options object](#options). +If we want to run these rules files then we would add the `rulesDirectory` field to the [options +object](#options). ## Endpoints diff --git a/packages/auth0/bin/start.mjs b/packages/auth0/bin/start.mjs index 39527174..98457cd3 100755 --- a/packages/auth0/bin/start.mjs +++ b/packages/auth0/bin/start.mjs @@ -1,13 +1,42 @@ #!/usr/bin/env node -import { simulation, defaultUser } from "../dist/index.mjs"; +import { + getCLIConfig, + simulation, + defaultUser, +} from "@simulacrum/auth0-simulator"; -const app = simulation(); -app.listen(4400, () => +const args = process.argv.slice(2); +const envs = [{ name: "env", value: /** @type {Record} */ (process.env) }]; + +async function main() { + const result = getCLIConfig({ args, envs }); + + if (result.type !== "config") { + console.log(result.text); + if (result.type === "error") { + process.exitCode = 1; + } + return; + } + + const app = simulation({ config: result.value }); + + const { server, port } = await app.listen(); + const info = server.address(); + const host = + typeof info === "object" && info?.address && !["::", "0.0.0.0"].includes(info.address) + ? info.address + : "localhost"; console.log( - `Auth0 simulation server started at https://localhost:4400\n` + + `Auth0 simulation server started at https://${host}:${port}\n` + `Visit the root route to view all available routes.\n\n` + `Point your configuration at this simulation server and use the default user below.\n` + `Email: ${defaultUser.email}\nPassword: ${defaultUser.password}\n` + `\nPress Ctrl+C to stop the server`, - ), -); + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/packages/auth0/example/index.mts b/packages/auth0/example/index.mts index 0dab9d50..05d7940f 100644 --- a/packages/auth0/example/index.mts +++ b/packages/auth0/example/index.mts @@ -9,8 +9,16 @@ let app = simulation({ }, }, }); -app.listen(undefined, () => + +app.listen().then(({ server, port }) => { + const info = server.address(); + const host = + typeof info === "object" && info?.address && !["::", "0.0.0.0"].includes(info.address) + ? info.address + : "localhost"; console.log( - `auth0 simulation server started at https://localhost:4400\nusername: default@example.com\npassword: 12345\n`, - ), -); + `Auth0 simulation server started at https://${host}:${port}\n` + + `username: default@example.com\n` + + `password: 12345\n`, + ); +}); diff --git a/packages/auth0/package.json b/packages/auth0/package.json index 33401894..f470599f 100644 --- a/packages/auth0/package.json +++ b/packages/auth0/package.json @@ -55,7 +55,7 @@ "lint": "oxlint", "prepack": "pnpm run build", "start": "node --experimental-transform-types ./example/index.mts", - "start:bin": "node ./bin/start.cjs", + "start:bin": "node ./bin/start.mjs", "test": "NODE_EXTRA_CA_CERTS=\"$(mkcert -CAROOT)/rootCA.pem\" vitest run --fileParallelism=false", "test:watch": "NODE_EXTRA_CA_CERTS=\"$(mkcert -CAROOT)/rootCA.pem\" vitest watch --fileParallelism=false", "tsc": "tsc --noEmit" @@ -65,14 +65,14 @@ "@simulacrum/foundation-simulator": "^0.8.0", "assert-ts": "^0.3.4", "base64-url": "^2.3.3", + "configliere": "^0.6.0", "cookie-session": "^2.1.0", "cors": "^2.8.6", - "cosmiconfig": "^9.0.0", "express": "^5.2.1", "html-entities": "^2.5.2", "jose": "^5.9.6", "jsesc": "^3.1.0", - "zod": "^3.24.1" + "zod": "^4.4.3" }, "devDependencies": { "@simulacrum/server": "workspace:^", diff --git a/packages/auth0/src/config/get-config.ts b/packages/auth0/src/config/get-config.ts index a428ee3f..b1d5f9c3 100644 --- a/packages/auth0/src/config/get-config.ts +++ b/packages/auth0/src/config/get-config.ts @@ -1,56 +1,288 @@ -import { cosmiconfigSync } from "cosmiconfig"; -import type { Auth0Configuration, ConfigSchema } from "../types.ts"; -import { configurationSchema } from "../types.ts"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createRequire } from "node:module"; +import { + checkpoint, + cli, + command, + description, + env, + type EnvSource, + name, + option, + parse as configliereParse, + printErrors, + printHelp, + printVersion, + type Result, + schema as optionSchema, + transform, + type ValueSource, + version, +} from "configliere"; +import { z } from "zod"; +import type { Auth0Configuration } from "../types.ts"; -const DefaultAuth0Port = 4400; +const pkg = createRequire(import.meta.url)("../../package.json") as { + name: string; + version: string; +}; -export const DefaultArgs: ConfigSchema = { - clientID: "00000000000000000000000000000000", +const fieldDefaults = { audience: "https://thefrontside.auth0.com/api/v1/", + clientID: "00000000000000000000000000000000", scope: "openid profile email offline_access", -}; + protocol: "https", +} satisfies Partial; -type Explorer = ReturnType; +export function readJsonConfig(path: string): Record { + const contents = readFileSync(resolve(path), "utf8"); + const parsed = JSON.parse(contents); -function getPort({ domain, port }: Auth0Configuration): number { - if (typeof port === "number") { - return port; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new TypeError(`Config file ${path} must contain a JSON object`); } - if (domain) { - const parts = domain.split(":"); - if (parts.length === 2) { - return parseInt(parts[1]!); - } + return parsed as Record; +} + +const configFields = { + port: z.optional( + z.number().gt(2999, "port must be greater than 2999").lt(10000, "port must be less than 10000"), + ), + domain: z.optional(z.string().min(1, "domain is required")), + audience: z.optional(z.string().min(1, "audience is required")), + clientID: z.optional(z.string().max(32, "must be 32 characters long")), + clientSecret: z.optional(z.string()), + scope: z.optional( + z.union([ + z.string().min(1, "scope is required"), + z.array( + z.object({ + clientID: z.string().max(32, "must be 32 characters long"), + audience: z.optional(z.string().min(1, "audience is required")), + scope: z.string().min(1, "scope is required"), + }), + ), + ]), + ), + rulesDirectory: z.optional(z.string()), + connection: z.optional(z.string()), + protocol: z.optional(z.enum(["http", "https"])), +}; + +const DefaultAuth0Port = 4400; + +type NormalizedConfig = Auth0Configuration & Record; + +function getDomainPort(domain: string): number | undefined { + if (domain.includes("://")) { + let url = new URL(domain); + return url.port ? Number(url.port) : undefined; } - return DefaultAuth0Port; + let match = domain.match(/:(\d+)$/); + return match ? Number(match[1]) : undefined; } -// This higher order function would only be used for testing and -// allows different cosmiconfig instances to be used for testing -export function getConfigCreator(explorer: Explorer) { - return function getConfig(options?: Partial): Auth0Configuration { - let searchResult = explorer.search(); +function withDomainPort(domain: string, port: number): string { + if (domain.includes("://")) { + let url = new URL(domain); + url.port = String(port); + return url.toString().replace(/\/$/, ""); + } - let config: ConfigSchema = searchResult === null ? DefaultArgs : searchResult.config; + return domain.replace(/:\d+$/, "") + `:${port}`; +} - let strippedOptions = options ?? {}; +function normalizeConfig(input: Record): NormalizedConfig { + let defined = Object.fromEntries( + Object.entries(input).filter(([key, value]) => key in configFields && value !== undefined), + ); + let config = { ...fieldDefaults, ...defined } as NormalizedConfig; + let port = config.port; - let configuration = { - ...DefaultArgs, + if (port === undefined) { + let domainPort = config.domain ? getDomainPort(config.domain) : undefined; + let resolvedPort = domainPort ?? DefaultAuth0Port; + return { ...config, - ...strippedOptions, - } as Auth0Configuration; + port: resolvedPort, + domain: + config.domain === undefined + ? `localhost:${resolvedPort}` + : withDomainPort(config.domain, resolvedPort), + }; + } - configuration.port = getPort(configuration); + if (config.domain === undefined) { + return { + ...config, + domain: `localhost:${port}`, + }; + } + + let domainPort = getDomainPort(config.domain); - configurationSchema.parse(configuration); + // Preserve a conflicting pair for the model schema to report as an issue. + if (domainPort !== undefined && domainPort !== port) { + return config; + } - return configuration; + return { + ...config, + domain: withDomainPort(config.domain, port), }; } -const explorer = cosmiconfigSync("auth0Simulator"); +const configSchema = z.object(configFields).transform((input, context): Auth0Configuration => { + if (input.domain !== undefined && input.port !== undefined) { + let domainPort = getDomainPort(input.domain); + if (domainPort !== undefined && domainPort !== input.port) { + context.addIssue({ + code: "custom", + message: `Configured domain ${input.domain} conflicts with port ${input.port}`, + }); + return z.NEVER; + } + } + + return normalizeConfig(input); +}); -export const getConfig = getConfigCreator(explorer); +// the config file path binds ahead of the values checkpoint so it is visible +// on the suspended model. everything after the checkpoint binds only once the +// checkpoint is resumed with the file's values, which sit below arguments and +// environment variables in the resolution order. +export const auth0App = command( + name(pkg.name), + description("Simulate the Auth0 API."), + version(pkg.version), + option( + name("config"), + description("path to a JSON config file"), + cli(["--config", "-c"]), + // An empty key disables the inferred CONFIG lookup. The file path is + // resolved from arguments only. + env(""), + optionSchema(z.optional(z.string())), + ), + checkpoint(), + transform( + configSchema, + option( + name("port"), + description("port to listen on"), + cli(["--port", "-p"]), + optionSchema(configFields.port), + ), + option(name("domain"), description("server domain"), optionSchema(configFields.domain)), + option(name("audience"), description("auth0 audience"), optionSchema(configFields.audience)), + option( + name("clientID"), + description("auth0 client ID"), + cli(["--client-id"]), + optionSchema(configFields.clientID), + ), + option( + name("clientSecret"), + description("client secret"), + cli(["--client-secret"]), + optionSchema(configFields.clientSecret), + ), + option(name("scope"), description("auth0 scope"), optionSchema(configFields.scope)), + option( + name("rulesDirectory"), + description("directory containing auth0 rules"), + cli(["--rules-directory"]), + optionSchema(configFields.rulesDirectory), + ), + option( + name("connection"), + description("auth0 connection"), + optionSchema(configFields.connection), + ), + option(name("protocol"), description("server protocol"), optionSchema(configFields.protocol)), + ), +); + +function parseConfig( + input: Parameters[1], +): ReturnType { + let first = configliereParse(auth0App, input); + + if (!first.ok || !first.resume) { + return first; + } + + let configPath = (first.model as { config?: unknown } | undefined)?.config; + let values: Result = + configPath === undefined ? { ok: true, value: [] } : loadConfig(String(configPath)); + + return first.resume(values); +} + +function loadConfig(path: string): Result { + try { + return { + ok: true, + value: [{ name: path, value: readJsonConfig(path) }], + }; + } catch (error) { + return { + ok: false, + issues: [ + { + message: error instanceof Error ? error.message : String(error), + }, + ], + }; + } +} + +export function getConfig(options?: Partial): Auth0Configuration { + let result = getCLIConfig({ + args: [], + values: options ? [{ name: "options", value: options }] : [], + }); + + if (result.type === "config") { + return result.value; + } + + if (result.type === "error") { + throw new Error(result.text); + } + + throw new Error(`unexpected config result: ${result.type}`); +} + +type CLIConfigInput = { + args: string[]; + envs?: readonly EnvSource[] | undefined; + values?: readonly ValueSource[] | undefined; +}; + +export type CLIConfigResult = + | { type: "help"; text: string } + | { type: "version"; text: string } + | { type: "error"; text: string } + | { type: "config"; value: Auth0Configuration }; + +export function getCLIConfig({ args, envs, values }: CLIConfigInput): CLIConfigResult { + let settled = parseConfig({ argv: args, envs: envs ?? [], values: values ?? [] }); + + if (!settled.ok) { + return { type: "error", text: printErrors(settled as never) }; + } + + if (settled.method === "help") { + return { type: "help", text: printHelp(settled as never) }; + } + + if (settled.method === "version") { + return { type: "version", text: printVersion(settled as never) }; + } + + return { type: "config", value: settled.model as Auth0Configuration }; +} diff --git a/packages/auth0/src/handlers/auth0-handlers.ts b/packages/auth0/src/handlers/auth0-handlers.ts index 09a52bd9..d3d91248 100644 --- a/packages/auth0/src/handlers/auth0-handlers.ts +++ b/packages/auth0/src/handlers/auth0-handlers.ts @@ -92,15 +92,15 @@ export const createAuth0Handlers = ( ["/login"]: function (req, res) { logger.log({ "/login": { body: req.body, query: req.query } }); let query = req.query as QueryParams; - let responseClientId = query.client_id ?? clientID; + let responseClientID = query.client_id ?? clientID; let responseAudience = query.audience ?? audience; - assert(!!responseClientId, `no clientID assigned`); + assert(!!responseClientID, `no clientID assigned`); let html = loginView({ domain: new URL(serviceURL(req)).host, scope, redirectUri: query.redirect_uri, - clientID: responseClientId, + clientID: responseClientID, audience: responseAudience, loginFailed: false, }); @@ -127,7 +127,7 @@ export const createAuth0Handlers = ( if (!user) { let query = req.query as QueryParams; - let responseClientId = query.client_id ?? clientID; + let responseClientID = query.client_id ?? clientID; let responseAudience = query.audience ?? audience; assert(!!clientID, `no clientID assigned`); @@ -136,7 +136,7 @@ export const createAuth0Handlers = ( domain: new URL(serviceURL(req)).host, scope, redirectUri: query.redirect_uri, - clientID: responseClientId, + clientID: responseClientID, audience: responseAudience, loginFailed: true, }); @@ -188,16 +188,16 @@ export const createAuth0Handlers = ( try { let iss = serviceURL(req); - let responseClientId: string = (req?.body?.client_id as string) ?? clientID; + let responseClientID: string = (req?.body?.client_id as string) ?? clientID; let responseAudience: string = (req?.body?.audience as string) ?? audience; - assert(!!responseClientId, "500::no clientID in options or request body"); + assert(!!responseClientID, "500::no clientID in options or request body"); let tokens = await createTokens({ simulationStore, body: req.body, iss, - clientID: responseClientId, + clientID: responseClientID, audience: responseAudience, rulesDirectory, scope, diff --git a/packages/auth0/src/index.ts b/packages/auth0/src/index.ts index 98a91cdf..9a84e7ac 100644 --- a/packages/auth0/src/index.ts +++ b/packages/auth0/src/index.ts @@ -21,19 +21,35 @@ export type Auth0Simulator = (args?: { extendRouter?: (router: Router, simulationStore: ExtendedSimulationStore) => void; }; options?: Partial; + config?: Auth0Configuration; }) => FoundationSimulator; -export const simulation: Auth0Simulator = (args = {}) => { - const config = getConfig(args.options); - const parsedInitialState = !args?.initialState +export const simulation: Auth0Simulator = ({ + debug, + initialState, + extend, + options, + config: suppliedConfig, +} = {}) => { + // if config is provided, use it. + // Otherwise, get the config from passed in options and defaults + const config = suppliedConfig ?? getConfig(options); + const parsedInitialState = initialState === undefined ? undefined - : auth0InitialStoreSchema.parse(args?.initialState); + : auth0InitialStoreSchema.parse(initialState); return createFoundationSimulationServer({ - port: config.port ?? 4400, // default port - protocol: "https", - extendStore: extendStore(parsedInitialState, args?.extend?.extendStore), - extendRouter: extendRouter(config, args.extend?.extendRouter, args.debug), + ...(config.port !== undefined && { port: config.port }), + ...(config.protocol !== undefined && { protocol: config.protocol }), + extendStore: extendStore(parsedInitialState, extend?.extendStore), + extendRouter: extendRouter(config, extend?.extendRouter, debug), })(); }; +export { + auth0App, + getCLIConfig, + getConfig, + readJsonConfig, + type CLIConfigResult, +} from "./config/get-config.ts"; export { auth0UserSchema, defaultUser } from "./store/entities.ts"; diff --git a/packages/auth0/src/types.ts b/packages/auth0/src/types.ts index 2171b376..6d1152bd 100644 --- a/packages/auth0/src/types.ts +++ b/packages/auth0/src/types.ts @@ -1,34 +1,3 @@ -import { z } from "zod"; - -export const configurationSchema = z.object({ - port: z.optional( - z.number().gt(2999, "port must be greater than 2999").lt(10000, "must be less than 10000"), - ), - domain: z.optional(z.string().min(1, "domain is required")), - audience: z.optional(z.string().min(1, "audience is required")), - clientID: z.optional(z.string().max(32, "must be 32 characters long")), - scope: z.union([ - z.string().min(1, "scope is required"), - z.array( - z.object({ - clientID: z.string().max(32, "must be 32 characters long"), - audience: z.optional(z.string().min(1, "audience is required")), - scope: z.string().min(1, "scope is required"), - }), - ), - ]), - clientSecret: z.optional(z.string()), - rulesDirectory: z.optional(z.string()), - auth0SessionCookieName: z.optional(z.string()), - auth0CookieSecret: z.optional(z.string()), - connection: z.optional(z.string()), - cookieSecret: z.optional(z.string()), -}); - -export type ConfigSchema = z.infer; - -type ReadonlyFields = "audience" | "clientID" | "scope" | "port"; - // grant_type list as defined by auth0 // https://auth0.com/docs/get-started/applications/application-grant-types#spec-conforming-grants export type GrantType = @@ -42,8 +11,18 @@ export type ScopeConfig = | string | { audience?: string | undefined; clientID: string; scope: string }[]; -export type Auth0Configuration = Required> & - Omit; +export interface Auth0Configuration { + port: number; + audience: string; + clientID: string; + scope: ScopeConfig; + domain?: string | undefined; + clientSecret?: string | undefined; + rulesDirectory?: string | undefined; + connection?: string | undefined; + protocol?: "http" | "https" | undefined; +} + export type ResponseModes = "query" | "web_message"; export type QueryParams = { diff --git a/packages/auth0/src/views/login.ts b/packages/auth0/src/views/login.ts index 8c294855..247792b5 100644 --- a/packages/auth0/src/views/login.ts +++ b/packages/auth0/src/views/login.ts @@ -27,7 +27,7 @@ export const loginView = ({ href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet" /> - + login diff --git a/packages/auth0/test/config.test.ts b/packages/auth0/test/config.test.ts new file mode 100644 index 00000000..4c45ea60 --- /dev/null +++ b/packages/auth0/test/config.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { getCLIConfig, getConfig } from "../src/index.ts"; + +describe("CLI config parsing", () => { + let tempDirectory: string | undefined; + + let envs = [{ name: "env", value: process.env as Record }]; + + afterEach(() => { + if (tempDirectory) { + rmSync(tempDirectory, { recursive: true, force: true }); + tempDirectory = undefined; + } + }); + + it("parses config directly from argv", () => { + let result = getCLIConfig({ args: ["--port", "4567"], envs }); + + expect(result.type).toBe("config"); + + if (result.type === "config") { + expect(result.value.port).toBe(4567); + } + }); + + it("loads a JSON config file before parsing remaining args", () => { + tempDirectory = mkdtempSync(join(tmpdir(), "auth0-config-")); + let configPath = join(tempDirectory, "config.json"); + writeFileSync(configPath, JSON.stringify({ port: 4567 }), "utf8"); + let clientID = "client-id-value-for-cli-merge-01"; + + let result = getCLIConfig({ args: ["-c", configPath, "--client-id", clientID], envs }); + + expect(result.type).toBe("config"); + + if (result.type === "config") { + expect(result.value.port).toBe(4567); + expect(result.value.clientID).toBe(clientID); + } + }); + + it("returns command help when requested", () => { + let result = getCLIConfig({ args: ["--help"], envs: [] }); + + expect(result.type).toBe("help"); + + if (result.type === "help") { + expect(result.text).toContain("[OPTIONS]"); + } + }); + + it("returns the program version when requested", () => { + let result = getCLIConfig({ args: ["--version"], envs: [] }); + + expect(result.type).toBe("version"); + + if (result.type === "version") { + expect(result.text).toMatch(/\d+\.\d+\.\d+/); + } + }); + + it("returns configuration errors as printable text", () => { + let result = getCLIConfig({ + args: ["--domain", "localhost:9999", "--port", "4567"], + envs: [], + }); + + expect(result.type).toBe("error"); + + if (result.type === "error") { + expect(result.text).toContain("conflicts with port 4567"); + } + }); + + it("falls back to environment variables for unbound parameters", () => { + let result = getCLIConfig({ + args: [], + envs: [{ name: "env", value: { PORT: "4588" } }], + }); + + expect(result.type).toBe("config"); + + if (result.type === "config") { + expect(result.value.port).toBe(4588); + } + }); + + it("resolves environment variables above config file values", () => { + tempDirectory = mkdtempSync(join(tmpdir(), "auth0-config-")); + let configPath = join(tempDirectory, "config.json"); + writeFileSync(configPath, JSON.stringify({ port: 4567 }), "utf8"); + + let result = getCLIConfig({ + args: ["-c", configPath], + envs: [{ name: "env", value: { PORT: "4588" } }], + }); + + expect(result.type).toBe("config"); + + if (result.type === "config") { + expect(result.value.port).toBe(4588); + } + }); + + it("resolves arguments above environment variables", () => { + let result = getCLIConfig({ + args: ["--port", "4567"], + envs: [{ name: "env", value: { PORT: "4588" } }], + }); + + expect(result.type).toBe("config"); + + if (result.type === "config") { + expect(result.value.port).toBe(4567); + } + }); + + it("does not read a config path from the environment", () => { + let result = getCLIConfig({ + args: [], + envs: [{ name: "env", value: { CONFIG: "/nonexistent/auth0-config.json" } }], + }); + + expect(result.type).toBe("config"); + }); + + it("derives domain from port for programmatic config", () => { + let config = getConfig({ port: 4567 }); + + expect(config.domain).toBe("localhost:4567"); + }); + + it("derives port from domain for programmatic config", () => { + let config = getConfig({ domain: "localhost:4567" }); + + expect(config.port).toBe(4567); + expect(config.domain).toBe("localhost:4567"); + }); + + it("throws when domain and port conflict", () => { + expect(() => getConfig({ domain: "localhost:9999", port: 4567 })).toThrow( + "conflicts with port 4567", + ); + }); +}); diff --git a/packages/github-api/package.json b/packages/github-api/package.json index 79bfe3bf..b11151ce 100644 --- a/packages/github-api/package.json +++ b/packages/github-api/package.json @@ -71,7 +71,7 @@ "express": "^5.2.1", "graphql": "^16.9.0", "graphql-yoga": "^5.15.1", - "zod": "^3.24.1" + "zod": "^4.4.3" }, "devDependencies": { "@graphql-codegen/cli": "^5.0.7", diff --git a/packages/github-api/src/store/entities.ts b/packages/github-api/src/store/entities.ts index 2d4d84c9..e1d7f8ba 100644 --- a/packages/github-api/src/store/entities.ts +++ b/packages/github-api/src/store/entities.ts @@ -292,7 +292,7 @@ export const githubOrganizationSchema = z .default(() => faker.date.recent().toISOString()) .optional(), - teams: z.union([z.array(z.string()), z.undefined()]), + teams: z.array(z.string()).optional(), avatar_url: z.string().optional().default("https://github.com/images/error/octocat_happy.gif"), gravatar_id: z.string().optional().default(""), site_admin: z.boolean().optional().default(true), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25573c1e..029088b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,15 +56,15 @@ importers: base64-url: specifier: ^2.3.3 version: 2.3.3 + configliere: + specifier: ^0.6.0 + version: 0.6.0 cookie-session: specifier: ^2.1.0 version: 2.1.1 cors: specifier: ^2.8.6 version: 2.8.6 - cosmiconfig: - specifier: ^9.0.0 - version: 9.0.1(typescript@5.8.3) express: specifier: ^5.2.1 version: 5.2.1 @@ -78,8 +78,8 @@ importers: specifier: ^3.1.0 version: 3.1.0 zod: - specifier: ^3.24.1 - version: 3.25.76 + specifier: ^4.4.3 + version: 4.5.4 devDependencies: '@simulacrum/server': specifier: workspace:^ @@ -169,8 +169,8 @@ importers: specifier: ^5.15.1 version: 5.18.1(graphql@16.13.2) zod: - specifier: ^3.24.1 - version: 3.25.76 + specifier: ^4.4.3 + version: 4.5.4 devDependencies: '@graphql-codegen/cli': specifier: ^5.0.7 @@ -1558,6 +1558,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -1910,6 +1913,10 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} + configliere@0.6.0: + resolution: {integrity: sha512-7c7Soc2hbumAZQYVmhjs56X9mngz+moui7yjog4CfSus/cXY2/qwkXLKsvOLM8NM7foELnaWCSeXbwOYFQfW0g==} + engines: {node: '>= 16'} + constant-case@3.0.4: resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} @@ -1957,15 +1964,6 @@ packages: typescript: optional: true - cosmiconfig@9.0.1: - resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -2081,10 +2079,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -3422,8 +3416,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} snapshots: @@ -4705,6 +4699,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.0': optional: true + '@standard-schema/spec@1.1.0': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -5096,6 +5092,10 @@ snapshots: common-tags@1.8.2: {} + configliere@0.6.0: + dependencies: + '@standard-schema/spec': 1.1.0 + constant-case@3.0.4: dependencies: no-case: 3.0.4 @@ -5142,15 +5142,6 @@ snapshots: optionalDependencies: typescript: 5.8.3 - cosmiconfig@9.0.1(typescript@5.8.3): - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - parse-json: 5.2.0 - optionalDependencies: - typescript: 5.8.3 - cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 @@ -5232,8 +5223,6 @@ snapshots: encodeurl@2.0.0: {} - env-paths@2.2.1: {} - error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -6632,4 +6621,4 @@ snapshots: yocto-queue@0.1.0: {} - zod@3.25.76: {} + zod@4.5.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4bf38520..8b3db6ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,9 @@ packages: - packages/* blockExoticSubdeps: true -# don't use workspace: protocol as covector will handle versions -linkWorkspacePackages: deep -disallowWorkspaceCycles: true catalog: effection: ^4.0.3 +disallowWorkspaceCycles: true +# don't use workspace: protocol as covector will handle versions +linkWorkspacePackages: deep diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..9b83ef0f --- /dev/null +++ b/todo.md @@ -0,0 +1,104 @@ +# Auth0 simulator config work — tracking + +## Resolved +- [x] `normalized()` wrapper fixes `inject()` bypass (old configliere's `inject` calls `inspect` directly). +- [x] CLI precedence documented to match old configliere: env vars > CLI flags > config file. +- [x] `paseo.json` added to `.gitignore`. +- [x] Migrated packages/auth0 to configliere preview @6935534 (two-phase parse, `fromValues()` reader, + `auth0App({values})` factory; `normalizeConfig`/`getConfig` untouched). Envs accepted in + `getCLIConfig` input but not read (forward-compat placeholder). +- [x] **Migrated packages/auth0 to configliere preview @064f111 (full phase-protocol rework).** + Tokenizer `claimPair` patch no longer needed (fixed upstream; patch machinery removed). + New shape in `get-config.ts`: + - `auth0App()` — config option (with `env("")` so an ambient `CONFIG` var can't be read as + a file path) → `checkpoint()` → nine field options → `routes(start)` with the same fields. + Values/envs are native parse inputs now; `fromValues()` reader wrapper is gone. + - `getCLIConfig` — parse suspends at the checkpoint; help/version are detected from raw + argv (`requestedMethod`) so a config file is never loaded for them; execute resumes with + the file values (`Result`) → CLI > env > config-file precedence natively. + - `getConfig(options)` — options threaded as parse `values` + empty resume. + - Merge of root + subcommand models strips child `undefined`s (child re-claims params with + distinct claim IDs and would otherwise shadow root-settled values). This also fixes a + latent old-code bug where a pre-`start` CLI value was clobbered by a file claim. + - Tests: 67 pass (62 prior + 5 new: env fallback, env>file, cli>env, start+file, no + `CONFIG` env leak). tsc, oxlint, build/attw/publint all clean. +- [x] All 62 auth0 tests pass (`--no-file-parallelism` + `NODE_OPTIONS=--use-system-ca`, both pre-existing). +- [x] Dropped `compose()` — fields bundle into one `auth0Fields(values)` element; both pipelines + are plain positional `route()` calls within the typed arity. +- [x] Zod schema moved out of types.ts into get-config.ts: `fieldDefs` table is now the single + source of truth (description/aliases/schema); `Auth0Configuration` is a hand-written type. + Validation happens once at binding (CLI strings decoded via schema; file values validated + too), so `finalize()` no longer re-parses. Phase-two parse can legitimately fail on bad + config files → both entry points surface binding issues instead of "unreachable" guards. + +## Configliere preview "Project Tom Hagen" (@6935534) +Ground-up rebuild as typed request router. Now the live dependency of @simulacrum/auth0-simulator. + +### Upstream bug found & patched +- `Tokenizer.claimPair` never advanced `previous` after a failed match, so pairs were tested as + `(first-token, tk)` — any `--flag value` preceded by another token failed ("requires a value" + + "unexpected"), and a leading flag could mis-pair with a non-adjacent word. Fix: advance + `previous = token` on failed match (strict adjacency). Carried in `patches/configliere.patch` + via `patchedDependencies` in pnpm-workspace.yaml (pnpm ≥10 ignores it in sub-package.json). + **Should be reported/fixed upstream** (configliere PR #20 / repo issues). + +### Costs / caveats +- [x] **Variadic-tuple alternative to the 30 overloads investigated & benchmarked** (bench harness + in /var/folders/.../opencode/pipe-bench): checked-chaining via `Chained`/`OutputOf` collapses + whenever an element's own generics must be inferred inline (e.g. `option("port")` inside the + pipeline) — TS can't do arg-inference + tuple-inference + deferred conditional in one pass; + pre-bound elements work but output types degrade to constraint instantiations and cost + ~4.6x more instantiations (827K vs 180K for 240 pipelines, ~2x check time). +- [x] **Delta design found that DOES work** (pipe-bench/delta.ts): elements are callable but carry + a type-level delta tag resolved eagerly at their own call site (`{k:"param", s:name}` etc.); + `route()` folds concrete deltas in one linear pass — no nested inference, no deferred + conditional in the inference path. Inline usage compiles with full precision (exact param + keys/methods/children threading, wrong keys rejected), and costs ~1.7x ladder instantiations + / ~1.2x check time (313K vs 180K, 0.69s vs 0.58s for 240 pipelines). +- [x] **Delta scaling measured + TS2589 fixed** (pipe-bench/scale.mjs): first fold version died at + ~100 elements (`Type instantiation is excessively deep`) because re-wrapping the accumulated + Route forced a conditional (`ParamsOf`) to resolve the whole lineage each step. Threading + a flat State record (indexed accesses only, Route built once at the end) removed it: + 5→500 elements all compile; wall ~1000-1500 (TS tail-call budget). Per-call cost grows + superlinearly (params intersections accumulate): depth 10 ≈ 0.7K inst / 3ms; depth 50 ≈ + 5.4K / 9ms; depth 100 ≈ 15.7K / 23ms. Ladder stays linear but hard-caps at 31. + Branch order in `Apply` mattered ~1.9x on the old design; method-first won. +- [x] **Envs wired natively** — `parse(app, { argv, envs })` landed in @562cb6a+; `getCLIConfig` + passes envs straight through (env keys: `PORT`, `CLIENT_ID`, `DOMAIN`, … upper-snake, + un-prefixed; child routes get `START_PORT`-style keys). Placeholder removed. +- [ ] Extension surface (`Param.cli`, `ReadCLI`, `CLIRead`) is exported but young. +- [ ] Package still published as `configliere@0.4.0-pr…` (README says `@frontside/configliere`). +- [x] Absent optionals arrive as explicit `undefined` (not omitted) — `finalize()` strips them + so field defaults aren't shadowed. +- [ ] **Upstream TS2589 budget (064f111): nine options exceed the instantiation budget** in the + delta `Fold`/`Materialize` pipeline — fails in every construction style (variadic inline, + tuple spread, manual chaining) once the type is actually consumed; plain `z.string()` + schemas too. Workaround: runtime app is built with shallow `AnyRoute` intermediates and + `auth0App()` anchors its return to a hand-written structural `Route` type (phases carry + the checkpoint resolver + typed models, so suspended/execute intents stay typed). + **Worth reporting upstream** — affects any ~8+ option CLI. +- [ ] **Upstream behavior change:** config-file values are claimed per-route-path, so subcommand + routes read *nested* keys (`{ start: { port } }`) from a root-mounted value source; our + merge instead relies on the root binding (which reads flat keys post-checkpoint). + README documents the nested shape as intended. + +## Pre-existing test environment issues (not from migration) +- [ ] Self-signed cert: Node 24 rejects leaf → tests need `NODE_OPTIONS=--use-system-ca`. + Fails identically at pristine HEAD. +- [ ] Suites share a fixed port-3000 server (test/helpers.ts); parallel file runs collide → + run vitest with `--no-file-parallelism` (or make ports dynamic per suite). + +## Upstream implementation handoff +- [ ] `delta-pipeline-handoff.md` (repo root) — self-contained doc for an agent to implement the + delta fold in configliere upstream: reference impl, integration notes (schema-typed param + values, multi-mount ParamsDelta generalization), gotchas (TS2589/State fix, branch order, + spread footgun), error-gallery parity, perf baselines, acceptance checklist. + +## Open design notes (current implementation) +- [ ] **`simulation({ config })` bypasses normalization** — raw object skips domain/port derivation + and conflict check; undefined port falls back to foundation's 9000 silently. +- [ ] **`port` typed required but parseable-as-undefined** until `normalizeConfig` runs. +- [ ] **README doesn't document `simulation({ config })` or `getCLIConfig`** — decide whether/how. +- [ ] **Passing both `config` and `options`** — `config` wins silently. Document or assert. +- [ ] **Duplicate flags now error instead of last-wins** — behavior change vs old configliere; + confirm intentional for release notes.