@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1

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 (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
package/dist/index.mjs CHANGED
@@ -14,12 +14,46 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { spawnSync } from "node:child_process";
17
- import { cpSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
17
+ import { appendFileSync, cpSync, existsSync, readdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
18
18
  import { dirname, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { shouldCopyTemplateEntry } from "./internal/template-filter.mjs";
21
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
- const TEMPLATES_DIR = resolve(__dirname, "../templates/standalone");
22
+ const TEMPLATES_ROOT = resolve(__dirname, "../templates");
23
+ /** The template a scaffold is made from when none is named. */
24
+ export const DEFAULT_TEMPLATE = "standalone";
25
+ /**
26
+ * The templates under `templatesRoot`: every directory — not a symbolic link,
27
+ * not dot-named — holding a `package.json`, sorted; none when the directory is
28
+ * missing. This is the rule `scripts/templates.mjs` copies templates by, read
29
+ * here from the copy, where `versions.json` sits beside them. A name is only
30
+ * ever looked up in this list, so nothing outside the templates directory can
31
+ * be named as a template.
32
+ */
33
+ export const availableTemplates = (templatesRoot = TEMPLATES_ROOT) => {
34
+ if (!existsSync(templatesRoot))
35
+ return [];
36
+ return readdirSync(templatesRoot, { withFileTypes: true })
37
+ .filter((entry) => entry.isDirectory() &&
38
+ !entry.name.startsWith(".") &&
39
+ existsSync(resolve(templatesRoot, entry.name, "package.json")))
40
+ .map((entry) => entry.name)
41
+ .sort();
42
+ };
43
+ /**
44
+ * Why `template` cannot be scaffolded from `templates`, or `undefined` when it
45
+ * can: none is bundled (a checkout that never ran the prebuild script), or it
46
+ * is not one of them.
47
+ */
48
+ export const templateRefusal = (template, templates) => {
49
+ if (templates.length === 0) {
50
+ return `No templates found at ${TEMPLATES_ROOT}. If developing locally, run the prebuild script first.`;
51
+ }
52
+ if (!templates.includes(template)) {
53
+ return `Unknown template '${template}'. Available templates: ${templates.join(", ")}.`;
54
+ }
55
+ return undefined;
56
+ };
23
57
  const getPackageVersions = () => {
24
58
  const versionFile = resolve(__dirname, "../templates/versions.json");
25
59
  if (existsSync(versionFile)) {
@@ -44,35 +78,47 @@ export const isValidDirName = (name) => {
44
78
  return false;
45
79
  return UNSCOPED_NAME_RE.test(name);
46
80
  };
47
- export const scaffold = (targetDir, projectName) => {
48
- if (!existsSync(TEMPLATES_DIR)) {
49
- throw new Error(`Template directory not found at ${TEMPLATES_DIR}. If developing locally, run the prebuild script first.`);
50
- }
81
+ /**
82
+ * Appended to the scaffold's `config/application.conf` by `noMfa`: the
83
+ * template's switch `mfaMode` written off, then bound to `MFA_MODE` so the
84
+ * variable wins over the written value. The MFA package stays a dependency.
85
+ */
86
+ const MFA_OFF_LINES = [
87
+ "",
88
+ "# written by create-app --no-mfa: MFA is off unless MFA_MODE turns it on",
89
+ 'mfaMode = "off"',
90
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a HOCON substitution, not a template
91
+ "mfaMode = ${?MFA_MODE}",
92
+ ];
93
+ export const scaffold = (targetDir, projectName, template = DEFAULT_TEMPLATE, options = {}) => {
94
+ const refusal = templateRefusal(template, availableTemplates());
95
+ if (refusal !== undefined)
96
+ throw new Error(refusal);
97
+ const templateDir = resolve(TEMPLATES_ROOT, template);
51
98
  // Copy template to target
52
- cpSync(TEMPLATES_DIR, targetDir, {
99
+ cpSync(templateDir, targetDir, {
53
100
  recursive: true,
54
- filter: (source) => shouldCopyTemplateEntry(source, TEMPLATES_DIR),
101
+ filter: (source) => shouldCopyTemplateEntry(source, templateDir),
55
102
  });
56
- // #407: npm drops a file literally named `.gitignore` from a published
57
- // package, so `copy-templates.mjs` stages it dot-less and it is restored
58
- // here. Without it the first `git add .` in a scaffolded project commits
59
- // the `.env` and the signing key the README's own setup steps say to
60
- // create — and it would have shipped that way silently, because the file
61
- // is present in the repository and only missing from the tarball.
103
+ // npm drops a file literally named `.gitignore` from a published package,
104
+ // so `copy-templates.mjs` stages it dot-less and it is restored here.
105
+ // Without it the first `git add .` in a scaffolded project commits the
106
+ // `.env` and the signing key the README's setup steps create.
62
107
  const stagedGitignore = resolve(targetDir, "gitignore");
63
108
  if (existsSync(stagedGitignore)) {
64
109
  renameSync(stagedGitignore, resolve(targetDir, ".gitignore"));
65
110
  }
111
+ if (options.noMfa === true) {
112
+ appendFileSync(resolve(targetDir, "config", "application.conf"), `${MFA_OFF_LINES.join("\n")}\n`);
113
+ }
66
114
  // Rewrite package.json
67
115
  const pkgPath = resolve(targetDir, "package.json");
68
116
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
69
117
  pkg.name = projectName;
70
- // `"private": true` is deliberately KEPT (o3co/auth.policy-verifier#126
71
- // item 4, same code in both scaffolders). The scaffold is an identity
72
- // provider — keys, config, policy — and deleting the field made an
73
- // accidental `npm publish` succeed by default. Publishing a scaffolded
74
- // service is the rare intent; the operator who has it states it by
75
- // removing the field.
118
+ // `"private": true` is deliberately kept: the scaffold is an identity
119
+ // provider (keys, config, policy), so an accidental `npm publish` must fail
120
+ // by default. An operator who means to publish removes the field.
121
+ // o3co/auth.policy-verifier's scaffolder does the same; change the two together.
76
122
  // Replace all workspace:* references with per-package published versions
77
123
  const versions = getPackageVersions();
78
124
  for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
@@ -91,11 +137,9 @@ export const scaffold = (targetDir, projectName) => {
91
137
  }
92
138
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
93
139
  // pnpm ≥10.29 reads `onlyBuiltDependencies` only from pnpm-workspace.yaml,
94
- // in single-package projects too — the template's former package.json
95
- // `pnpm` block is silently ignored (#360). The template cannot ship the
96
- // file: inside the monorepo it would shadow the workspace root for anyone
97
- // running pnpm from the template directory. So the scaffold writes it,
98
- // mirroring the allowlist the workspace root keeps for the same dependency.
140
+ // even in single-package projects. The template cannot ship the file:
141
+ // inside the monorepo it would shadow the workspace root. So the scaffold
142
+ // writes it, mirroring the workspace root's allowlist.
99
143
  writeFileSync(resolve(targetDir, "pnpm-workspace.yaml"), [
100
144
  "# pnpm (>= 10.29) reads onlyBuiltDependencies from this file, even in a",
101
145
  "# single-package project. bcrypt's install hook compiles the native addon",
@@ -114,25 +158,19 @@ export const scaffold = (targetDir, projectName) => {
114
158
  */
115
159
  const LOCKFILE_ARGS = ["install", "--lockfile-only", "--ignore-workspace"];
116
160
  /**
117
- * Launchers tried in order. `pnpm` on PATH is the common case; `corepack pnpm`
118
- * answers the machine that has Node's bundled corepack but no global pnpm.
119
- * The fallback fires only on ENOENT — the one launch error a different
120
- * launcher can answer. A non-zero exit (offline, private registry,
121
- * unpublished version) would fail identically through any launcher, and
122
- * EACCES-shaped errors surface as themselves rather than as "package manager
123
- * missing".
161
+ * Launchers tried in order: `pnpm` on PATH, then `corepack pnpm` for a machine
162
+ * with Node's bundled corepack but no global pnpm. Only ENOENT falls through to
163
+ * the next launcher; a non-zero exit would fail identically through any of
164
+ * them, and other launch errors surface as themselves.
124
165
  */
125
166
  const LOCKFILE_LAUNCHERS = [["pnpm"], ["corepack", "pnpm"]];
126
167
  /**
127
- * Resolve the scaffolded project's dependency graph into `pnpm-lock.yaml`.
128
- *
129
- * This is what makes the template's `pnpm install --frozen-lockfile` build
130
- * possible: the lockfile cannot be shipped with the template, because the
131
- * dependency set it would pin does not exist until `scaffold` has replaced
132
- * every `workspace:*` with a published version (#289). It is therefore
133
- * resolved once, here, against the rewritten `package.json`. Best-effort by
134
- * design — it needs a package manager and a reachable registry — so failure
135
- * is a result, not a throw.
168
+ * Resolve the scaffolded project's dependency graph into `pnpm-lock.yaml`,
169
+ * which the template's `pnpm install --frozen-lockfile` build needs. The
170
+ * lockfile cannot ship with the template: the dependency set it pins exists
171
+ * only after `scaffold` has replaced every `workspace:*`. Best-effort (it needs
172
+ * a package manager and a reachable registry), so failure is a result, not a
173
+ * throw.
136
174
  */
137
175
  export const generateLockfile = (targetDir) => {
138
176
  const attempts = [];
@@ -161,31 +199,39 @@ export const generateLockfile = (targetDir) => {
161
199
  }
162
200
  return { ok: false, reason: attempts.join("; ") };
163
201
  };
202
+ /** The flags that take a value, as `--flag <value>` or `--flag=<value>`. */
203
+ const VALUE_FLAGS = ["--dir", "--template"];
164
204
  const parseArgs = (args) => {
165
205
  const positionals = [];
166
- let dir;
167
- let dirSeen = false;
206
+ const values = new Map();
168
207
  let lockfile = true;
208
+ let noMfa = false;
209
+ const setValue = (flag, value) => {
210
+ if (values.has(flag))
211
+ throw new Error(`${flag} specified more than once`);
212
+ if (value === "")
213
+ throw new Error(`${flag} requires a value`);
214
+ values.set(flag, value);
215
+ };
169
216
  for (let i = 0; i < args.length; i++) {
170
217
  const a = args[i];
171
- if (a === "--dir") {
172
- if (dirSeen)
173
- throw new Error("--dir specified more than once");
218
+ const spaced = VALUE_FLAGS.find((flag) => a === flag);
219
+ const joined = VALUE_FLAGS.find((flag) => a.startsWith(`${flag}=`));
220
+ if (spaced !== undefined) {
174
221
  if (i + 1 >= args.length)
175
- throw new Error("--dir requires a value");
176
- dir = args[i + 1];
177
- dirSeen = true;
222
+ throw new Error(`${spaced} requires a value`);
223
+ setValue(spaced, args[i + 1]);
178
224
  i++;
179
225
  }
180
- else if (a.startsWith("--dir=")) {
181
- if (dirSeen)
182
- throw new Error("--dir specified more than once");
183
- dir = a.slice("--dir=".length);
184
- dirSeen = true;
226
+ else if (joined !== undefined) {
227
+ setValue(joined, a.slice(`${joined}=`.length));
185
228
  }
186
229
  else if (a === "--no-lockfile") {
187
230
  lockfile = false;
188
231
  }
232
+ else if (a === "--no-mfa") {
233
+ noMfa = true;
234
+ }
189
235
  else if (a.startsWith("-")) {
190
236
  // Treats `--` and any --unknown as an unknown flag.
191
237
  throw new Error(`unknown flag: ${a}`);
@@ -198,7 +244,13 @@ const parseArgs = (args) => {
198
244
  throw new Error("missing <project-name>");
199
245
  if (positionals.length > 1)
200
246
  throw new Error("too many positional arguments");
201
- return { projectName: positionals[0], dir, lockfile };
247
+ return {
248
+ projectName: positionals[0],
249
+ dir: values.get("--dir"),
250
+ template: values.get("--template"),
251
+ lockfile,
252
+ noMfa,
253
+ };
202
254
  };
203
255
  const deriveDirName = (projectName, dir) => {
204
256
  if (dir !== undefined)
@@ -224,11 +276,13 @@ export const main = () => {
224
276
  }
225
277
  catch (e) {
226
278
  console.error(`Error: ${e.message}`);
227
- console.error("Usage: @o3co/create-auth-provider <project-name> [--dir <dir-name>] [--no-lockfile]");
279
+ console.error("Usage: @o3co/create-auth-provider <project-name> [--template <name>] [--dir <dir-name>] [--no-lockfile] [--no-mfa]");
228
280
  console.error("<project-name> must be a valid npm package name (scoped like @scope/pkg, or unscoped).");
281
+ console.error(`--template names the template to copy (default: ${DEFAULT_TEMPLATE}); available: ${availableTemplates().join(", ")}.`);
229
282
  process.exit(1);
230
283
  }
231
- const { projectName, dir, lockfile } = parsed;
284
+ const { projectName, dir, lockfile, noMfa } = parsed;
285
+ const template = parsed.template ?? DEFAULT_TEMPLATE;
232
286
  if (!isValidProjectName(projectName)) {
233
287
  console.error("Error: <project-name> must be a valid npm package name (scoped like @scope/pkg, or unscoped; max 214 chars; no backslashes; no extra '/' beyond the single scope separator).");
234
288
  process.exit(1);
@@ -237,14 +291,19 @@ export const main = () => {
237
291
  console.error("Error: --dir must be a valid unscoped package name (no '/', '\\', '@'; not '.' or '..'; max 214 chars).");
238
292
  process.exit(1);
239
293
  }
294
+ const refusal = templateRefusal(template, availableTemplates());
295
+ if (refusal !== undefined) {
296
+ console.error(`Error: ${refusal}`);
297
+ process.exit(1);
298
+ }
240
299
  const dirName = deriveDirName(projectName, dir);
241
300
  const targetDir = resolve(process.cwd(), dirName);
242
301
  if (existsSync(targetDir)) {
243
302
  console.error(`Error: Directory '${dirName}' already exists.`);
244
303
  process.exit(1);
245
304
  }
246
- console.log(`Creating ${projectName}...`);
247
- scaffold(targetDir, projectName);
305
+ console.log(`Creating ${projectName} from the ${template} template...`);
306
+ scaffold(targetDir, projectName, template, { noMfa });
248
307
  let lockfileGenerated = false;
249
308
  if (lockfile) {
250
309
  console.log("\nResolving dependencies into pnpm-lock.yaml...");
@@ -1,17 +1,14 @@
1
1
  /**
2
2
  * Decide whether `cpSync` should copy a given source path.
3
3
  *
4
- * Only segments INSIDE `templateRoot` are checked against EXCLUDED_DIRS — the
5
- * install-prefix path above the root (e.g. `~/.npm/_npx/<hash>/node_modules/...`
6
- * when the package is run via `npx`) is ignored. Otherwise the filter would
7
- * reject every file whenever the package itself happens to live under a
8
- * `node_modules` directory (which is the v0.5.0 npx regression this fixes).
4
+ * Only segments inside `templateRoot` are checked against EXCLUDED_DIRS; the
5
+ * install prefix above it (e.g. `~/.npm/_npx/<hash>/node_modules/...` under
6
+ * `npx`) is ignored, or every file would be rejected whenever the package
7
+ * itself lives under a `node_modules` directory.
9
8
  *
10
- * The `pathSep` parameter exists so unit tests can exercise both POSIX and
11
- * Windows separators regardless of the host platform; production callers omit
12
- * it and pick up `path.sep` of the running platform. DO NOT change this to a
13
- * hardcoded `"/"` — `cpSync` passes back-slash-delimited absolute paths on
14
- * Windows, so segment splitting must follow the platform separator.
9
+ * `pathSep` lets tests exercise POSIX and Windows separators on any host.
10
+ * Production callers keep the `path.sep` default: `cpSync` passes
11
+ * backslash-delimited paths on Windows.
15
12
  */
16
13
  export declare const shouldCopyTemplateEntry: (source: string, templateRoot: string, pathSep?: string) => boolean;
17
14
  //# sourceMappingURL=template-filter.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"template-filter.d.mts","sourceRoot":"","sources":["../../src/internal/template-filter.mts"],"names":[],"mappings":"AAuBA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,uBAAuB,GACnC,QAAQ,MAAM,EACd,cAAc,MAAM,EACpB,UAAS,MAAY,KACnB,OAMF,CAAC"}
1
+ {"version":3,"file":"template-filter.d.mts","sourceRoot":"","sources":["../../src/internal/template-filter.mts"],"names":[],"mappings":"AAuBA;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,GACnC,QAAQ,MAAM,EACd,cAAc,MAAM,EACpB,UAAS,MAAY,KACnB,OAMF,CAAC"}
@@ -22,17 +22,14 @@ const EXCLUDED_DIRS = new Set(["node_modules", "dist"]);
22
22
  /**
23
23
  * Decide whether `cpSync` should copy a given source path.
24
24
  *
25
- * Only segments INSIDE `templateRoot` are checked against EXCLUDED_DIRS — the
26
- * install-prefix path above the root (e.g. `~/.npm/_npx/<hash>/node_modules/...`
27
- * when the package is run via `npx`) is ignored. Otherwise the filter would
28
- * reject every file whenever the package itself happens to live under a
29
- * `node_modules` directory (which is the v0.5.0 npx regression this fixes).
25
+ * Only segments inside `templateRoot` are checked against EXCLUDED_DIRS; the
26
+ * install prefix above it (e.g. `~/.npm/_npx/<hash>/node_modules/...` under
27
+ * `npx`) is ignored, or every file would be rejected whenever the package
28
+ * itself lives under a `node_modules` directory.
30
29
  *
31
- * The `pathSep` parameter exists so unit tests can exercise both POSIX and
32
- * Windows separators regardless of the host platform; production callers omit
33
- * it and pick up `path.sep` of the running platform. DO NOT change this to a
34
- * hardcoded `"/"` — `cpSync` passes back-slash-delimited absolute paths on
35
- * Windows, so segment splitting must follow the platform separator.
30
+ * `pathSep` lets tests exercise POSIX and Windows separators on any host.
31
+ * Production callers keep the `path.sep` default: `cpSync` passes
32
+ * backslash-delimited paths on Windows.
36
33
  */
37
34
  export const shouldCopyTemplateEntry = (source, templateRoot, pathSep = sep) => {
38
35
  if (source === templateRoot)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.15.0",
4
- "description": "Scaffold a new o3co/auth.provider project from the standalone template",
3
+ "version": "0.17.0-rc.1",
4
+ "description": "Scaffold a new o3co/auth.provider project from one of its templates",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "repository": {
@@ -28,8 +28,10 @@
28
28
  "node": ">=22.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@types/node": "^26.5.1",
32
- "vitest": "^5.0.0"
31
+ "@o3co/ts.hocon": "^1.13.0",
32
+ "@types/node": "^26.6.3",
33
+ "typescript": "^6.0.3",
34
+ "vitest": "^5.0.2"
33
35
  },
34
36
  "scripts": {
35
37
  "build": "tsc",
@@ -1,9 +1,22 @@
1
1
  node_modules
2
2
  dist
3
3
  .git
4
- .env*
5
- !.env.example
6
4
  Makefile
5
+ # #705: the secrets and the per-deployment configuration that `.gitignore`
6
+ # keeps out of git stay out of the build context as well. The builder stage
7
+ # COPYs `config/` and the runtime image takes it from there, so a
8
+ # `config/clients.yaml` in the working copy — client secrets included — was
9
+ # baked into a layer that travels with every push; the production compose
10
+ # mounts the registry instead. This is `.gitignore`'s secrets section, written
11
+ # with `**/` where it matches at any depth: a `.dockerignore` pattern is
12
+ # anchored at the context root. Keep the two in step; scaffold-assets.test.mts
13
+ # checks that they are.
14
+ **/.env*
15
+ !**/.env.example
16
+ **/*.pem
17
+ **/*.key
18
+ config/clients.yaml
19
+ config/*.local.conf
7
20
  # .gitignore, the READMEs and the compose files stay in the context: the
8
21
  # `test` image copies them because the shipped suite reads them (#512). The
9
22
  # runtime image never copies them, so they cost the context a few kilobytes
@@ -12,10 +12,10 @@ OAUTH_JWT_ISSUER=
12
12
  # openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
13
13
  #
14
14
  # Keep jwt-private.pem out of version control and off the image.
15
- OAUTH_JWT_PRIVATE_KEY_PATH=./jwt-private.pem
16
- OAUTH_JWT_PUBLIC_KEY_PATH=./jwt-public.pem
17
- # OAUTH_JWT_ALGORITHM=EdDSA # EdDSA | ES256 | RS256 | HS256
18
- # OAUTH_JWT_KID=v0
15
+ KEY_STORE_LOCAL_PRIVATE_KEY_PATH=./jwt-private.pem
16
+ KEY_STORE_LOCAL_PUBLIC_KEY_PATH=./jwt-public.pem
17
+ # KEY_STORE_LOCAL_ALGORITHM=EdDSA # EdDSA | ES256 | RS256 | HS256
18
+ # KEY_STORE_LOCAL_KID=v0
19
19
  #
20
20
  # HS256 alternative — symmetric, so /.well-known/jwks.json publishes NOTHING
21
21
  # and every relying party must hold a secret that can also MINT tokens. Only
@@ -23,22 +23,39 @@ OAUTH_JWT_PUBLIC_KEY_PATH=./jwt-public.pem
23
23
  # openssl rand -hex 32
24
24
  # Hex/base64 values are measured DECODED, so a 32-character hex string counts
25
25
  # as 16 bytes and is refused.
26
- # OAUTH_JWT_ALGORITHM=HS256
27
- # OAUTH_JWT_SECRET=
26
+ # KEY_STORE_LOCAL_ALGORITHM=HS256
27
+ # KEY_STORE_LOCAL_SECRET=
28
28
 
29
29
  # --- Session (required) ------------------------------------------------------
30
30
  # Signs the cookie that IS the authenticated session, so guessing it forges
31
31
  # logins. Same >= 32-byte floor and same decoded measurement:
32
32
  # openssl rand -hex 32
33
- SESSION_SECRET=
33
+ SESSION_STORE_SECRET=
34
34
 
35
35
  # User repository
36
36
  # Both URLs carry plaintext user credentials to the upstream store, so both must
37
37
  # be absolute https URLs. http:// is accepted only for a loopback host
38
38
  # (localhost, 127.0.0.0/8, [::1]) — a private-range address or a compose service
39
39
  # name such as http://user-service:8080 still needs https. Boot fails otherwise.
40
- CLIENT_USER_AUTHENTICATE_URL=
41
- CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL=
40
+ # Each must be the endpoint that answers: no request follows a 3xx, so a URL that
41
+ # redirects fails every call.
42
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_URL=
43
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL=
44
+ # With MFA on: the Store's endpoint the MFA enrollment witness is written to,
45
+ # so a Store that loses a subject's factors cannot open a first binding to
46
+ # whoever holds the password. Your Store must then answer `mfaEnrolled` on
47
+ # both URLs above. Unset, boot warns (mfa_enrollment_witness_unwritable).
48
+ # REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL=https://users.example.com/mfa/enrolled
49
+ # The credential this server presents to the Store: every Store request carries
50
+ # `Authorization: Bearer <token>`. Without one, anyone who can reach the Store
51
+ # can resolve an identity to its user, so your Store should refuse any request
52
+ # without it, with 401 and `WWW-Authenticate: Bearer error="invalid_token"`
53
+ # (which this server then reports as an outage naming the refused credential).
54
+ # The one token goes to every Store URL above: they must be one trust domain.
55
+ # At least 32 bytes, measured like SESSION_STORE_SECRET; boot fails on a weak or
56
+ # malformed value. Left commented out because a blank value is refused too:
57
+ # openssl rand -hex 32
58
+ # REPOSITORIES_USER_HTTP_BEARER_TOKEN=
42
59
 
43
60
  # --- Reverse proxy (required in production) -----------------------------------
44
61
  # Express `trust proxy`. Set this to the address or CIDR range of whatever
@@ -53,44 +70,72 @@ CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL=
53
70
  # limit shares one bucket. A plain-HTTP local run needs nothing here.
54
71
  HTTP_TRUST_PROXY=
55
72
 
56
- # Redis (code repository + session storage + the shared client socket)
73
+ # Redis (session storage + the shared client socket)
57
74
  # Use redis://redis:6379 when running via docker compose
58
- CLIENT_CODE_ENDPOINT_URI=redis://redis:6379
59
- SESSION_STORAGE_REDIS_URL=redis://redis:6379
75
+ SESSION_STORE_STORAGE_REDIS_URL=redis://redis:6379
60
76
  # #407: this one backs the SHARED ioredis socket -- the refresh-token family
61
77
  # store, the access-token denylist and the rate limiter all ride on it. It
62
78
  # defaults to redis://localhost:6379, which inside the container is the
63
79
  # container, so leaving it unset makes the compose boot dial nothing.
64
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL=redis://redis:6379
80
+ REDIS_CLIENTS_URL=redis://redis:6379
65
81
 
66
82
  # --- Federation grants (#593, optional) --------------------------------------
67
83
  # Offline delegation of an upstream IdP's tokens to a backend or an agent, on a
68
84
  # consent that survives logout. Off by default; on, boot refuses what is
69
85
  # missing by name. The consent page is yours (README: Federation Grants). The
70
86
  # connections and the encryption key ring are lists, so they live in a HOCON
71
- # layer, not here. Both stores ship on Redis; Redis GRANTS need
72
- # USER_SESSION_STORES_ADAPTER=redis beside them (the compose files set it) —
87
+ # layer, not here. Both stores ship as none: select each (redis, or memory on
88
+ # one replica) with the two ADAPTERS_ lines below. Redis GRANTS need
89
+ # ADAPTERS_USER_SESSION_STORES=redis beside them (the compose files set it) —
73
90
  # the grants would outlive a memory boundary that ends them.
74
91
  # FEDERATION_GRANTS_ENABLED=true
75
92
  # FEDERATION_GRANTS_CONSENT_URL=/consent/grants
76
- # `required` seals credentials under federationGrants.encryptionKeys (HOCON);
93
+ # `required` seals credentials under redis-federation-grant-store.encryptionKeys (HOCON);
77
94
  # `allow-plaintext` is refused in production/staging and under multi.
78
- # FEDERATION_GRANTS_ENCRYPTION_MODE=required
95
+ # REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE=required
79
96
  # `required` needs a user repository that covers every connection's
80
97
  # registration: the shipped http one asks your Store at the URL below and
81
98
  # covers what repositories.user.http.federatedIdentityLookupCoverage (HOCON)
82
99
  # declares; `unsupported` records that the check is not made.
83
100
  # FEDERATION_GRANTS_IDENTITY_LOOKUP=required
84
- # CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL=https://users.example.com/find-subject-by-federated-identity
85
- # FEDERATION_GRANT_STORE_ADAPTER=redis
86
- # FEDERATION_GRANT_INTENT_STORE_ADAPTER=redis
101
+ # REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL=https://users.example.com/find-subject-by-federated-identity
102
+ # ADAPTERS_FEDERATION_GRANT_STORE=redis
103
+ # ADAPTERS_FEDERATION_GRANT_INTENT_STORE=redis
104
+
105
+ # --- Multi-factor authentication (on by default) -----------------------------
106
+ # MFA_MODE: required (the default: every password login needs a second
107
+ # factor) | optional | off. The MFA page is yours (README: Multi-factor
108
+ # authentication). Development needs nothing here: config/development.conf
109
+ # holds a sample key, the codes are logged, and docker-compose.yml keeps the
110
+ # two MFA stores on its Redis. Outside development, set all of the following,
111
+ # or boot is refused naming them:
112
+ # - the two MFA stores, which ship in memory, refused outside development and
113
+ # test since a restart empties them of every factor: redis (or, for the
114
+ # factors, store);
115
+ # - a key of your own (openssl rand -base64 32);
116
+ # - the SMTP relay the account-email proof and the email factor's codes go
117
+ # through.
118
+ # The MFA page's URL defaults to /mfa, which you must serve, in development
119
+ # too, or every password login stops there.
120
+ # ADAPTERS_MFA_FACTOR_STORE=redis
121
+ # ADAPTERS_MFA_TRANSACTION_STORE=redis
122
+ # MFA_ENCRYPTION_KEY=
123
+ # STANDARD_SMTP_MAIL_SENDER_HOST=smtp.example.com
124
+ # STANDARD_SMTP_MAIL_SENDER_FROM=auth@example.com
125
+ # MFA_PAGE_URL=/mfa
126
+ # Or run without MFA:
127
+ # MFA_MODE=off
128
+ # To read the mail in development, leave every STANDARD_SMTP_MAIL_SENDER_*
129
+ # unset (USER and PASSWORD included) and run the Mailpit
130
+ # overlay, which sets them (README: Mailpit for the SMTP sender):
131
+ # docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
87
132
 
88
133
  # Development (override for local HTTP).
89
134
  # The default cookie name carries the `__Host-` prefix, which browsers only
90
135
  # accept on a Secure cookie — so dropping Secure means renaming the cookie too,
91
136
  # or boot refuses the combination.
92
- SESSION_SECURE=false
93
- SESSION_NAME=auth.sid
137
+ SESSION_STORE_SECURE=false
138
+ SESSION_STORE_NAME=auth.sid
94
139
 
95
140
  # Optional
96
141
  # HTTP_PORT=3000
@@ -101,5 +146,5 @@ SESSION_NAME=auth.sid
101
146
  # that one is unset.
102
147
  # OAUTH_ACCESS_TOKEN_EXPIRES_IN=3600
103
148
  # OAUTH_REFRESH_TOKEN_EXPIRES_IN=86400
104
- # SESSION_MAX_AGE=3600000
105
- # SESSION_SAME_SITE=lax # `none` additionally requires SESSION_SECURE=true
149
+ # SESSION_STORE_MAX_AGE=3600000
150
+ # SESSION_STORE_SAME_SITE=lax # `none` additionally requires SESSION_STORE_SECURE=true
@@ -3,7 +3,7 @@
3
3
  # rebuild (#289). The digest is the multi-arch index, so amd64 and arm64 both
4
4
  # resolve from it. Dependabot's `docker` ecosystem keeps the pair in step —
5
5
  # bump the tag and the digest together, never the tag alone.
6
- FROM node:26-alpine@sha256:ef24c5053d50fdc3e4e56eb4e7ddb7861874ab0fdc797046ba897581deb8e868 AS node-base
6
+ FROM node:26-alpine@sha256:0b36e8c136b94cd4fcf02188228e76c31ad5872eef3fec8cbd2eee500cfd9e80 AS node-base
7
7
 
8
8
  ENV HOME=/home/node
9
9
 
@@ -48,6 +48,10 @@ FROM deps AS builder
48
48
  USER node
49
49
 
50
50
  COPY --chown=node:node tsconfig.json ./
51
+ # What `.dockerignore` lets through (#705): the committed configuration layers
52
+ # and the example, never `clients.yaml` or a key. The runtime image copies
53
+ # this directory, so anything per-deployment here would be baked into it; the
54
+ # production compose mounts the client registry instead.
51
55
  COPY --chown=node:node config/ ./config/
52
56
  COPY --chown=node:node src/ ./src/
53
57
 
@@ -92,7 +96,7 @@ EXPOSE ${HTTP_PORT}
92
96
  # per-service, which keeps the restart-driving signal out of it.
93
97
  #
94
98
  # Use the shell form so ${HTTP_PORT} expands at probe time. The HOCON
95
- # config reads `${?HTTP_PORT}` for `http.port`, so app.listen() and the
99
+ # config reads `${?HTTP_PORT}` for `http.port`, so the listener and the
96
100
  # probe stay in sync regardless of the operator's port choice.
97
101
  HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
98
102
  CMD wget -q -O /dev/null "http://localhost:${HTTP_PORT}/_healthcheck" || exit 1
@@ -111,7 +115,7 @@ COPY --chown=node:node tests/ ./tests/
111
115
  # on files that were never a test's business. `.dockerignore` lets them into
112
116
  # the context for the same reason; nothing copies them into `runtime`.
113
117
  COPY --chown=node:node Dockerfile .dockerignore .env.example .gitignore README.md README.ja.md \
114
- docker-compose.yml docker-compose.production.yml ./
118
+ docker-compose.yml docker-compose.production.yml docker-compose.mailpit.yml ./
115
119
 
116
120
  CMD ["pnpm", "run", "test", "--", "--passWithNoTests"]
117
121