@monster-orange/den 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +99 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +406 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +108 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +729 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. package/src/ui/server/workspace-routes.ts +2154 -0
@@ -0,0 +1,416 @@
1
+ /**
2
+ * API JWT Token Manager
3
+ *
4
+ * Mints access tokens for direct API calls through the environment's token
5
+ * provider (auth0 password grant, cognito, local-jwt — see
6
+ * ./token-providers). Tokens are cached to disk and reused until 5 minutes
7
+ * before expiry.
8
+ *
9
+ * Prerequisites when the provider is auth0 (one-time Auth0 Dashboard setup):
10
+ * 1. Applications → [App] → Settings → Advanced Settings → Grant Types
11
+ * → enable "Password"
12
+ * 2. Auth0 Dashboard → Settings → API Authorization Settings
13
+ * → Default Directory = "Username-Password-Authentication"
14
+ *
15
+ * Credential sources (same priority as headless browser login):
16
+ * 1. ENV vars: E2E_ADMIN_EMAIL / E2E_ADMIN_PASSWORD (CI only)
17
+ * 2. Secrets backend (Doppler / 1Password / env — src/shared/secrets),
18
+ * at the accounts named by `test_accounts` in the org config
19
+ *
20
+ * Note: social-login (e.g. Google) accounts cannot be used — the password
21
+ * grant requires email+password.
22
+ *
23
+ * Cache location: playwright/.auth/api-token-{role}-{env}.json (gitignored)
24
+ */
25
+
26
+ import * as fs from "fs";
27
+ import * as path from "path";
28
+ import { getCredentials } from "./headless-login.js";
29
+ import { mintAccessToken } from "./token-providers/index.js";
30
+ import type { Environment } from "../types/index.js";
31
+ import { getDistRoot } from "../paths.js";
32
+ import { envSelector } from "../environment/env-selector.js";
33
+
34
+ import { product } from "../product.js";
35
+ // Anchored to the den root, NOT process.cwd(): token caches are WRITTEN
36
+ // here, and `den test ...` can be invoked from any directory (bin/den
37
+ // never cds; spawned playwright children inherit the user's cwd).
38
+ const AUTH_DIR = path.resolve(getDistRoot(), "playwright/.auth");
39
+ const EXPIRY_BUFFER_MS = 5 * 60 * 1000; // refresh 5 min before actual expiry
40
+
41
+ // Per-environment Auth0 client secrets (gitignored).
42
+ // Keys are environment names; values are client_secret strings.
43
+ // Created by: echo '{"staging":"<secret>"}' > config/local/auth0-client-secrets.json
44
+ const AUTH0_SECRETS_FILE = path.resolve(
45
+ getDistRoot(),
46
+ "config/local/auth0-client-secrets.json",
47
+ );
48
+
49
+ function loadAuth0ClientSecret(environment: string): string | undefined {
50
+ try {
51
+ if (!fs.existsSync(AUTH0_SECRETS_FILE)) return undefined;
52
+ const secrets = JSON.parse(
53
+ fs.readFileSync(AUTH0_SECRETS_FILE, "utf-8"),
54
+ ) as Record<string, string>;
55
+ return secrets[environment] ?? undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Parse a .env / .env.local file and return key→value pairs.
63
+ * Handles quoted values, inline comments, and blank/comment lines.
64
+ */
65
+ function parseDotEnv(filePath: string): Record<string, string> {
66
+ try {
67
+ if (!fs.existsSync(filePath)) return {};
68
+ const result: Record<string, string> = {};
69
+ for (const line of fs.readFileSync(filePath, "utf-8").split("\n")) {
70
+ const trimmed = line.trim();
71
+ if (!trimmed || trimmed.startsWith("#")) continue;
72
+ const eqIdx = trimmed.indexOf("=");
73
+ if (eqIdx === -1) continue;
74
+ const key = trimmed.slice(0, eqIdx).trim();
75
+ const value = trimmed
76
+ .slice(eqIdx + 1)
77
+ .trim()
78
+ .replace(/^["']|["']$/g, ""); // strip surrounding quotes
79
+ result[key] = value;
80
+ }
81
+ return result;
82
+ } catch {
83
+ return {};
84
+ }
85
+ }
86
+
87
+ interface CachedToken {
88
+ accessToken: string;
89
+ tokenType: string;
90
+ expiresAt: number; // epoch ms
91
+ role: string;
92
+ email: string;
93
+ }
94
+
95
+ function cachePath(
96
+ role: "admin" | "user" | "beta" | "root_admin",
97
+ environment: string,
98
+ ): string {
99
+ return path.join(AUTH_DIR, `api-token-${role}-${environment}.json`);
100
+ }
101
+
102
+ function loadCache(filePath: string): CachedToken | null {
103
+ try {
104
+ if (!fs.existsSync(filePath)) return null;
105
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8")) as CachedToken;
106
+ if (Date.now() >= data.expiresAt - EXPIRY_BUFFER_MS) return null; // expired / near-expiry
107
+ return data;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ function saveCache(filePath: string, token: CachedToken): void {
114
+ // Cached JWTs are secrets — keep the dir and file owner-only (0700/0600).
115
+ if (!fs.existsSync(AUTH_DIR))
116
+ fs.mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
117
+ fs.writeFileSync(filePath, JSON.stringify(token, null, 2), { mode: 0o600 });
118
+ fs.chmodSync(filePath, 0o600); // mode arg is ignored if the file already exists
119
+ }
120
+
121
+ /**
122
+ * The dotenv file named by the environment config's
123
+ * `auth0.clientSecretEnvFile` (relative to the den root, e.g. an app
124
+ * checkout's `.env.local`), or undefined when none is configured.
125
+ */
126
+ async function clientSecretEnvFile(
127
+ environment: Environment,
128
+ ): Promise<string | undefined> {
129
+ try {
130
+ const cfg = await envSelector.loadEnvironment(environment);
131
+ const file = cfg.auth0?.clientSecretEnvFile?.trim();
132
+ return file ? path.resolve(getDistRoot(), file) : undefined;
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Resolve the Auth0 client secret from the file-based local-dev fallbacks
140
+ * (config/local/auth0-client-secrets.json keyed by environment, else
141
+ * AUTH0_CLIENT_SECRET in the configured `auth0.clientSecretEnvFile`), with
142
+ * a label naming where it came from. Pure read — never mutates process.env.
143
+ */
144
+ async function resolveFileClientSecret(
145
+ environment: Environment,
146
+ ): Promise<{ secret: string; source: string } | undefined> {
147
+ const fromJson = loadAuth0ClientSecret(environment);
148
+ if (fromJson) {
149
+ return {
150
+ secret: fromJson,
151
+ source: `config/local/auth0-client-secrets.json (${environment})`,
152
+ };
153
+ }
154
+ const envFile = await clientSecretEnvFile(environment);
155
+ const fromEnvFile = envFile
156
+ ? parseDotEnv(envFile)["AUTH0_CLIENT_SECRET"]
157
+ : undefined;
158
+ return fromEnvFile && envFile
159
+ ? { secret: fromEnvFile, source: envFile }
160
+ : undefined;
161
+ }
162
+
163
+ /**
164
+ * Wrap `mintAuth0JWT` with operator-friendly error hints for the well-known
165
+ * Auth0 misconfigurations hit when first enabling the password grant.
166
+ * Keeps the elaborate setup guidance attached to the shared test-user flow,
167
+ * while the loadtest per-user flow uses the bare `mintAuth0JWT` (its operators
168
+ * are already familiar with the setup).
169
+ */
170
+ async function fetchRopcToken(
171
+ environment: Environment,
172
+ email: string,
173
+ password: string,
174
+ options: { clientSecret?: string } = {},
175
+ ): Promise<{ access_token: string; token_type: string; expires_in: number }> {
176
+ try {
177
+ const minted = await mintAccessToken(environment, email, password, options);
178
+ return {
179
+ access_token: minted.accessToken,
180
+ token_type: minted.tokenType,
181
+ expires_in: minted.expiresIn,
182
+ };
183
+ } catch (err) {
184
+ const msg = (err as Error).message ?? String(err);
185
+ const isGrantError =
186
+ msg.includes("grant type") ||
187
+ msg.includes("Password grant") ||
188
+ msg.includes("unauthorized_client");
189
+
190
+ if (isGrantError) {
191
+ throw new Error(
192
+ `Auth0 ROPC not enabled: ${msg}\n\n` +
193
+ ` To enable the Password grant:\n` +
194
+ ` 1. Auth0 Dashboard → Applications → [your app]\n` +
195
+ ` 2. Settings → Advanced Settings → Grant Types → check "Password"\n` +
196
+ ` 3. Auth0 Dashboard → Settings → API Authorization Settings\n` +
197
+ ` → Default Directory = "Username-Password-Authentication"\n` +
198
+ ` 4. Save and retry.\n`,
199
+ );
200
+ }
201
+
202
+ const isDefaultDirectoryMissing =
203
+ msg.includes("access_denied") && msg.includes("Unauthorized");
204
+
205
+ if (isDefaultDirectoryMissing) {
206
+ throw new Error(
207
+ `Auth0 ROPC failed: Password grant is enabled but "Default Directory" is not set.\n\n` +
208
+ ` Fix (one-time Auth0 Dashboard change):\n` +
209
+ ` Auth0 Dashboard → Settings → API Authorization Settings\n` +
210
+ ` → Default Directory = "Username-Password-Authentication"\n` +
211
+ ` → Save Changes\n\n` +
212
+ ` Note: This is separate from the Grant Types setting on the Application.`,
213
+ );
214
+ }
215
+
216
+ const isCredentialError =
217
+ msg.includes("invalid_grant") ||
218
+ msg.includes("Wrong email") ||
219
+ msg.includes("Wrong password") ||
220
+ msg.includes("user not found");
221
+
222
+ if (isCredentialError) {
223
+ throw new Error(
224
+ `Auth0 login failed for "${email}": ${msg}\n` +
225
+ ` Check the test-account secret in your secrets backend (make ${product().cli}-setup-secrets)`,
226
+ );
227
+ }
228
+
229
+ throw err;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Get a JWT access token for the given role, suitable for
235
+ * Authorization: Bearer <token> headers when calling the app's API directly.
236
+ *
237
+ * Checks disk cache first. Fetches a fresh token via ROPC only when the
238
+ * cache is missing or within 5 minutes of expiry.
239
+ *
240
+ * @throws if credentials are unavailable or Auth0 ROPC fails
241
+ */
242
+ export async function getApiToken(
243
+ role: "admin" | "user" | "beta" | "root_admin",
244
+ environment: Environment,
245
+ ): Promise<string> {
246
+ const cache = cachePath(role, environment);
247
+
248
+ // Return cached token if still valid
249
+ const cached = loadCache(cache);
250
+ if (cached) {
251
+ const minsLeft = Math.round((cached.expiresAt - Date.now()) / 60000);
252
+ console.log(
253
+ ` ✓ Using cached API token for ${role} (${minsLeft}m remaining)`,
254
+ );
255
+ return cached.accessToken;
256
+ }
257
+
258
+ console.log(` Fetching API token for ${role}...`);
259
+
260
+ // Load credentials (ENV vars or credentials file)
261
+ const creds = await getCredentials(role, environment);
262
+ if (!creds) {
263
+ const hint =
264
+ role === "admin" || role === "root_admin"
265
+ ? ` • Local ${role}: set test_accounts.roles.${role} in the org config, or run make auth-configure for a personal account\n`
266
+ : ` • Local ${role}: set test_accounts.roles.${role} in the org config and store its password in your secrets backend\n`;
267
+ throw new Error(
268
+ `No credentials available for ${role} API token.\n` +
269
+ ` Options:\n` +
270
+ ` • CI: set E2E_${role.toUpperCase()}_EMAIL / E2E_${role.toUpperCase()}_PASSWORD\n` +
271
+ ` • Local: ensure the secrets backend is signed in (make ${product().cli}-setup-secrets)\n` +
272
+ hint +
273
+ ` Note: social-login accounts cannot be used for a password grant — email+password only.`,
274
+ );
275
+ }
276
+
277
+ // Client secret priority (surfaced via E2E_AUTH0_CLIENT_SECRET env var):
278
+ // 1. E2E_AUTH0_CLIENT_SECRET env var (CI) — passed through unchanged
279
+ // 2. config/local/auth0-client-secrets.json keyed by environment (gitignored)
280
+ // 3. AUTH0_CLIENT_SECRET in the environment's auth0.clientSecretEnvFile
281
+ //
282
+ // mintAuth0JWT reads E2E_AUTH0_CLIENT_SECRET, so we set it here from the
283
+ // file-based fallbacks if not already in the env.
284
+ if (!process.env.E2E_AUTH0_CLIENT_SECRET) {
285
+ const fromFile = await resolveFileClientSecret(environment);
286
+ if (fromFile) {
287
+ console.log(` Using client_secret from ${fromFile.source}`);
288
+ process.env.E2E_AUTH0_CLIENT_SECRET = fromFile.secret;
289
+ }
290
+ } else {
291
+ console.log(` Using client_secret from E2E_AUTH0_CLIENT_SECRET`);
292
+ }
293
+
294
+ const data = await fetchRopcToken(environment, creds.email, creds.password);
295
+
296
+ const expiresAt = Date.now() + data.expires_in * 1000;
297
+ const token: CachedToken = {
298
+ accessToken: data.access_token,
299
+ tokenType: data.token_type,
300
+ expiresAt,
301
+ role,
302
+ email: creds.email,
303
+ };
304
+
305
+ saveCache(cache, token);
306
+
307
+ const minsValid = Math.round(data.expires_in / 60);
308
+ console.log(
309
+ ` ✓ API token fetched for ${role} (${creds.email}), valid for ${minsValid}m`,
310
+ );
311
+
312
+ return data.access_token;
313
+ }
314
+
315
+ /** Injectable collaborators for {@link mintFreshApiToken} (unit tests). */
316
+ export interface MintFreshApiTokenDeps {
317
+ getCredentials?: typeof getCredentials;
318
+ mintJwt?: typeof mintAccessToken;
319
+ }
320
+
321
+ /**
322
+ * Mint a fresh JWT for the given role, MEMORY-ONLY: unlike
323
+ * {@link getApiToken}, this path never reads or writes the disk cache
324
+ * under playwright/.auth and never mutates process.env — the Auth0 client
325
+ * secret (env var first, else the file-based local-dev fallbacks) is
326
+ * passed explicitly into the mint. Built for the journeys runner, whose
327
+ * captured secrets must not outlive the run in any persistent store.
328
+ *
329
+ * @throws if credentials are unavailable or Auth0 ROPC fails
330
+ */
331
+ export async function mintFreshApiToken(
332
+ role: "admin" | "user" | "beta" | "root_admin",
333
+ environment: Environment,
334
+ deps: MintFreshApiTokenDeps = {},
335
+ ): Promise<string> {
336
+ const resolveCredentials = deps.getCredentials ?? getCredentials;
337
+ const creds = await resolveCredentials(role, environment);
338
+ if (!creds) {
339
+ throw new Error(
340
+ `No credentials available for ${role} API token.\n` +
341
+ ` Options:\n` +
342
+ ` • CI: set E2E_${role.toUpperCase()}_EMAIL / E2E_${role.toUpperCase()}_PASSWORD\n` +
343
+ ` • Local: ensure the secrets backend is signed in (make ${product().cli}-setup-secrets)\n` +
344
+ ` Note: social-login accounts cannot be used for a password grant — email+password only.`,
345
+ );
346
+ }
347
+
348
+ const clientSecret =
349
+ process.env.E2E_AUTH0_CLIENT_SECRET ??
350
+ (await resolveFileClientSecret(environment))?.secret;
351
+
352
+ if (deps.mintJwt) {
353
+ const minted = await deps.mintJwt(
354
+ environment,
355
+ creds.email,
356
+ creds.password,
357
+ {
358
+ ...(clientSecret !== undefined ? { clientSecret } : {}),
359
+ },
360
+ );
361
+ return minted.accessToken;
362
+ }
363
+ const data = await fetchRopcToken(
364
+ environment,
365
+ creds.email,
366
+ creds.password,
367
+ clientSecret !== undefined ? { clientSecret } : {},
368
+ );
369
+ return data.access_token;
370
+ }
371
+
372
+ /**
373
+ * Delete the cached API token for a role/environment, forcing a fresh
374
+ * ROPC fetch on the next call to getApiToken().
375
+ */
376
+ export function clearApiTokenCache(
377
+ role: "admin" | "user" | "beta" | "root_admin",
378
+ environment: Environment,
379
+ ): void {
380
+ const file = cachePath(role, environment);
381
+ if (fs.existsSync(file)) {
382
+ fs.unlinkSync(file);
383
+ console.log(` ✓ API token cache cleared: ${role}/${environment}`);
384
+ } else {
385
+ console.log(` (no cached token for ${role}/${environment})`);
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Return status info about the cached token for a role/environment.
391
+ * Used by make int-jwt-status.
392
+ */
393
+ export function getApiTokenCacheStatus(
394
+ role: "admin" | "user" | "beta" | "root_admin",
395
+ environment: string,
396
+ ): {
397
+ found: boolean;
398
+ email?: string;
399
+ expiresAt?: number;
400
+ minsRemaining?: number;
401
+ } {
402
+ const file = cachePath(role, environment as Environment);
403
+ try {
404
+ if (!fs.existsSync(file)) return { found: false };
405
+ const data = JSON.parse(fs.readFileSync(file, "utf-8")) as CachedToken;
406
+ const minsRemaining = Math.round((data.expiresAt - Date.now()) / 60000);
407
+ return {
408
+ found: true,
409
+ email: data.email,
410
+ expiresAt: data.expiresAt,
411
+ minsRemaining,
412
+ };
413
+ } catch {
414
+ return { found: false };
415
+ }
416
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Auth0 Helper
3
+ * Auth0-specific authentication for Next.js frontend testing
4
+ */
5
+
6
+ import { AuthenticationClient } from "auth0";
7
+ import type { Page } from "@playwright/test";
8
+ import type { TokenSet } from "../types/index.js";
9
+
10
+ export interface Auth0Config {
11
+ domain: string;
12
+ clientId: string;
13
+ clientSecret?: string;
14
+ audience?: string;
15
+ }
16
+
17
+ export class Auth0Helper {
18
+ private auth0Client: AuthenticationClient;
19
+ private config: Auth0Config;
20
+
21
+ constructor(config: Auth0Config) {
22
+ this.config = config;
23
+ this.auth0Client = new AuthenticationClient({
24
+ domain: config.domain,
25
+ clientId: config.clientId,
26
+ clientSecret: config.clientSecret,
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Login with username and password (Resource Owner Password Grant)
32
+ * For API testing and automated flows
33
+ */
34
+ async loginWithPassword(
35
+ username: string,
36
+ password: string,
37
+ ): Promise<TokenSet> {
38
+ try {
39
+ const result = await this.auth0Client.oauth.passwordGrant({
40
+ username,
41
+ password,
42
+ realm: "Username-Password-Authentication",
43
+ scope: "openid profile email",
44
+ audience: this.config.audience,
45
+ });
46
+
47
+ const data = result.data as unknown as Record<string, unknown>;
48
+ return {
49
+ access_token: data.access_token as string,
50
+ refresh_token: data.refresh_token as string | undefined,
51
+ id_token: data.id_token as string | undefined,
52
+ expires_in: data.expires_in as number | undefined,
53
+ token_type: data.token_type as string | undefined,
54
+ scope: data.scope as string | undefined,
55
+ };
56
+ } catch (error: unknown) {
57
+ const msg =
58
+ error instanceof Error ? error.message : JSON.stringify(error);
59
+ throw new Error(`Auth0 password login failed: ${msg}`);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Complete Auth0 Universal Login flow in browser (for Playwright tests)
65
+ * Handles the Auth0 login page specifically
66
+ * @istanbul ignore next - Browser automation tested via E2E tests
67
+ */
68
+ async completeAuth0LoginInBrowser(
69
+ page: Page,
70
+ username: string,
71
+ password: string,
72
+ options: {
73
+ waitForUrl?: string | RegExp;
74
+ timeout?: number;
75
+ } = {},
76
+ ): Promise<void> {
77
+ const { waitForUrl = /.*\/dashboard.*/, timeout = 15000 } = options;
78
+
79
+ try {
80
+ // Wait for Auth0 Universal Login page to load
81
+ // Auth0 typically shows email/username first
82
+ await page.waitForSelector(
83
+ 'input[name="username"], input[type="email"]',
84
+ {
85
+ timeout,
86
+ },
87
+ );
88
+
89
+ // Fill in email/username
90
+ const usernameInput = page
91
+ .locator('input[name="username"], input[type="email"]')
92
+ .first();
93
+ await usernameInput.fill(username);
94
+
95
+ // Auth0 Universal Login may have a continue button before password
96
+ const continueButton = page
97
+ .locator('button[type="submit"], button[name="action"]')
98
+ .first();
99
+ if (
100
+ await continueButton.isVisible({ timeout: 1000 }).catch(() => false)
101
+ ) {
102
+ await continueButton.click();
103
+
104
+ // Wait for password field to appear
105
+ await page.waitForSelector(
106
+ 'input[name="password"], input[type="password"]',
107
+ {
108
+ timeout: 5000,
109
+ },
110
+ );
111
+ }
112
+
113
+ // Fill in password
114
+ const passwordInput = page
115
+ .locator('input[name="password"], input[type="password"]')
116
+ .first();
117
+ await passwordInput.fill(password);
118
+
119
+ // Submit the form
120
+ const submitButton = page
121
+ .locator('button[type="submit"], button[name="action"]')
122
+ .first();
123
+ await submitButton.click();
124
+
125
+ // Wait for redirect back to application
126
+ await page.waitForURL(waitForUrl, { timeout });
127
+ } catch (error) {
128
+ // Take screenshot for debugging
129
+ const screenshot = await page
130
+ .screenshot({ fullPage: true })
131
+ .catch(() => null);
132
+ if (screenshot) {
133
+ console.error("Login failed, screenshot saved");
134
+ }
135
+
136
+ throw new Error(
137
+ `Auth0 browser login failed: ${(error as Error).message}`,
138
+ );
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Refresh access token using refresh token
144
+ */
145
+ async refreshToken(refreshToken: string): Promise<TokenSet> {
146
+ try {
147
+ const result = await this.auth0Client.oauth.refreshTokenGrant({
148
+ refresh_token: refreshToken,
149
+ });
150
+
151
+ const data = result.data as unknown as Record<string, unknown>;
152
+ return {
153
+ access_token: data.access_token as string,
154
+ refresh_token: data.refresh_token as string | undefined,
155
+ id_token: data.id_token as string | undefined,
156
+ expires_in: data.expires_in as number | undefined,
157
+ token_type: data.token_type as string | undefined,
158
+ scope: data.scope as string | undefined,
159
+ };
160
+ } catch (error: unknown) {
161
+ const msg =
162
+ error instanceof Error ? error.message : JSON.stringify(error);
163
+ throw new Error(`Auth0 token refresh failed: ${msg}`);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Get user info from Auth0
169
+ * Note: Uses the /userinfo endpoint directly
170
+ */
171
+ async getUserInfo(accessToken: string): Promise<Record<string, unknown>> {
172
+ try {
173
+ const response = await fetch(`https://${this.config.domain}/userinfo`, {
174
+ headers: {
175
+ Authorization: `Bearer ${accessToken}`,
176
+ },
177
+ });
178
+
179
+ if (!response.ok) {
180
+ throw new Error(`Failed to get user info: ${response.status}`);
181
+ }
182
+
183
+ return (await response.json()) as Record<string, unknown>;
184
+ } catch (error: unknown) {
185
+ const msg =
186
+ error instanceof Error ? error.message : JSON.stringify(error);
187
+ throw new Error(`Failed to get user info: ${msg}`);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Client credentials grant (for machine-to-machine authentication)
193
+ */
194
+ async clientCredentialsGrant(audience?: string): Promise<TokenSet> {
195
+ if (!this.config.clientSecret) {
196
+ throw new Error("Client secret is required for client credentials grant");
197
+ }
198
+
199
+ try {
200
+ const result = await this.auth0Client.oauth.clientCredentialsGrant({
201
+ audience:
202
+ audience ||
203
+ this.config.audience ||
204
+ `https://${this.config.domain}/api/v2/`,
205
+ });
206
+
207
+ const data = result.data as unknown as Record<string, unknown>;
208
+ return {
209
+ access_token: data.access_token as string,
210
+ expires_in: data.expires_in as number | undefined,
211
+ token_type: data.token_type as string | undefined,
212
+ scope: data.scope as string | undefined,
213
+ };
214
+ } catch (error: unknown) {
215
+ const msg =
216
+ error instanceof Error ? error.message : JSON.stringify(error);
217
+ throw new Error(`Auth0 client credentials grant failed: ${msg}`);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Wait for Auth0 session in browser
223
+ * Useful for checking if user is already logged in
224
+ */
225
+ async waitForAuth0Session(page: Page): Promise<boolean> {
226
+ try {
227
+ // Check for Auth0 session cookie or localStorage
228
+ const hasSession = await page.evaluate(() => {
229
+ // @ts-expect-error - window is available in browser context
230
+ // Check localStorage for Auth0 session (Next.js with @auth0/nextjs-auth0)
231
+ const auth0Keys = Object.keys(window.localStorage).filter(
232
+ (key: string) =>
233
+ key.includes("auth0") || key.includes("@@auth0spajs@@"),
234
+ );
235
+
236
+ if (auth0Keys.length > 0) {
237
+ return true;
238
+ }
239
+
240
+ // @ts-expect-error - window is available in browser context
241
+ // Check for session cookie
242
+ const cookies = window.document.cookie.split(";");
243
+ return cookies.some((cookie: string) =>
244
+ cookie.trim().startsWith("appSession="),
245
+ );
246
+ });
247
+
248
+ return hasSession;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Clear Auth0 session from browser
256
+ * Useful for test cleanup
257
+ */
258
+ async clearAuth0Session(page: Page): Promise<void> {
259
+ try {
260
+ await page.evaluate(() => {
261
+ // @ts-expect-error - window is available in browser context
262
+ // Clear localStorage
263
+ const auth0Keys = Object.keys(window.localStorage).filter(
264
+ (key: string) =>
265
+ key.includes("auth0") || key.includes("@@auth0spajs@@"),
266
+ );
267
+ // @ts-expect-error - window is available in browser context
268
+ auth0Keys.forEach((key: string) => window.localStorage.removeItem(key));
269
+
270
+ // @ts-expect-error - window is available in browser context
271
+ // Clear cookies
272
+ window.document.cookie.split(";").forEach((cookie: string) => {
273
+ const [name] = cookie.split("=");
274
+ // @ts-expect-error - window is available in browser context
275
+ window.document.cookie = `${name.trim()}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
276
+ });
277
+ });
278
+ } catch (error) {
279
+ console.warn(
280
+ `Failed to clear Auth0 session: ${(error as Error).message}`,
281
+ );
282
+ }
283
+ }
284
+ }