@o3co/create-auth-provider 0.17.0 → 0.18.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 (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,355 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- /**
18
- * The scaffold wires an audit sink. `emitAuditEvent` is a no-op when the slot
19
- * is empty, so without one every security-relevant event the routes emit
20
- * (`token.issued.failure`, `authorize.rejected`, `rate_limit.unavailable`, …)
21
- * is dropped, and nothing fails or warns.
22
- *
23
- * Pinned from three sides: the sink implementation, the module that resolves
24
- * it from config, and the manifest, which must contain exactly one provider
25
- * for the slot.
26
- */
27
-
28
- import { generateKeyPairSync } from "node:crypto";
29
- import { readFileSync } from "node:fs";
30
- import {
31
- type AppConfig,
32
- type AuditEvent,
33
- type AuditSink,
34
- createApp,
35
- createKeyStoreFactory,
36
- defineModule,
37
- InMemoryClientRepository,
38
- InMemoryUserRepository,
39
- type Logger,
40
- memoryRefreshTokenFamilyStoreModule,
41
- registerBuiltinKeyStores,
42
- } from "@o3co/auth-provider-core";
43
- import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
44
- import express from "express";
45
- import request from "supertest";
46
- import { afterEach, describe, expect, it, vi } from "vitest";
47
- import { buildModules } from "../buildModules.mjs";
48
- import type { Switches } from "../configPath.mjs";
49
- import { createAppLogger, createAuditLogger, createLoggerAuditSink } from "../logger.mjs";
50
- import { auditSinkModuleFor } from "../modules.mjs";
51
- import { keyStoreSectionSchema } from "../sections.mjs";
52
- import { capturedRenames, shippedAdapters } from "./library-references.fixture.mjs";
53
-
54
- const keyPair = generateKeyPairSync("ed25519", {
55
- publicKeyEncoding: { type: "spki", format: "pem" },
56
- privateKeyEncoding: { type: "pkcs8", format: "pem" },
57
- });
58
-
59
- /**
60
- * Phase one's switches beside the configuration boot is handed; the template
61
- * modules' sections sit beside core's, so the configuration is wider than
62
- * `AppConfig`.
63
- */
64
- const baseConfig: Switches & AppConfig & Record<string, unknown> = {
65
- // The shipped selections, every store in this process.
66
- adapters: {
67
- ...shippedAdapters(),
68
- accessTokenDenylist: "memory",
69
- replaySeenSet: "memory",
70
- codeRepository: "memory",
71
- userRepository: "yaml",
72
- },
73
- mfaMode: "off",
74
- storeTransport: undefined,
75
- // What a resolution under an environment that sets none captures of
76
- // core's renamed variables.
77
- ...{
78
- "renamed-variables": capturedRenames({}),
79
- },
80
- http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000, cors: { allowedOrigins: [] } },
81
- logging: { level: "silent" },
82
- "key-store": {
83
- provider: "local",
84
- local: {
85
- algorithm: "EdDSA",
86
- kid: "v0",
87
- privateKey: keyPair.privateKey,
88
- publicKey: keyPair.publicKey,
89
- previousKeys: [],
90
- },
91
- },
92
- // The shipped `application.conf` expects no session requirement (ADR
93
- // 2026-09-28-session-admission).
94
- ...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
95
- oauth: {
96
- jwt: {
97
- issuer: "https://auth.test",
98
- },
99
- accessToken: { defaultExpiresIn: 3600 },
100
- refreshToken: { expiresIn: 86400 },
101
- grants: {},
102
- oidcMode: "oidc-required",
103
- },
104
- "session-store": {
105
- secret: "test-session-secret.at-least-32-bytes.ok",
106
- name: "auth.sid",
107
- maxAge: 3600000,
108
- secure: false,
109
- sameSite: "lax",
110
- domain: null,
111
- storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
112
- },
113
- session: {
114
- loginPage: { url: "/login" },
115
- rateLimit: { login: { windowMs: 60000, limit: 10 } },
116
- },
117
- rateLimit: { failMode: "open" },
118
- "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
119
- // The in-process limiter's section as core's `reference.conf` ships it.
120
- "core-rate-limiter-memory": {
121
- maxBuckets: 10000,
122
- defaultLimit: { limit: 60, windowSeconds: 60 },
123
- limits: {},
124
- },
125
- };
126
-
127
- const CLIENT_ID = "audit-client";
128
- const CLIENT_SECRET = "audit-secret";
129
- const BASIC_AUTH = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
130
-
131
- const testRepositoriesModule = defineModule({
132
- name: "test:repositories",
133
- provides: {
134
- clientRepository: () =>
135
- new InMemoryClientRepository(
136
- new Map([
137
- [
138
- CLIENT_ID,
139
- {
140
- tokenEndpointAuthMethod: "client_secret_basic",
141
- clientSecret: CLIENT_SECRET,
142
- allowedRedirectUris: [],
143
- allowedScopes: [],
144
- allowedAudiences: [],
145
- backchannelLogoutSessionRequired: true,
146
- frontchannelLogoutSessionRequired: true,
147
- allowedAzpForFederationToken: false,
148
- },
149
- ],
150
- ]),
151
- ),
152
- userRepository: () => new InMemoryUserRepository(new Map()),
153
- },
154
- });
155
-
156
- // The template's `key-store` section, read by its own schema, without the
157
- // section's relocation bridge.
158
- const testKeyStoreModule = defineModule({
159
- name: "key-store",
160
- section: { schema: keyStoreSectionSchema },
161
- provides: {
162
- keyStore: async ({ section }) => {
163
- const factory = createKeyStoreFactory();
164
- registerBuiltinKeyStores(factory);
165
- return factory.create({
166
- type: "local",
167
- ...(section.local ?? {}),
168
- });
169
- },
170
- },
171
- });
172
-
173
- /** Minimal `Logger` double — the interface is pino's, so only what we assert on. */
174
- function fakeLogger(): Logger & { info: ReturnType<typeof vi.fn> } {
175
- const self = {
176
- trace: vi.fn(),
177
- debug: vi.fn(),
178
- info: vi.fn(),
179
- warn: vi.fn(),
180
- error: vi.fn(),
181
- fatal: vi.fn(),
182
- child: () => self,
183
- };
184
- return self as unknown as Logger & { info: ReturnType<typeof vi.fn> };
185
- }
186
-
187
- /** Resolve `auditSink` the way the boot planner does: call the module's provider with its section. */
188
- async function resolveSink(sink: string, section?: Record<string, unknown>): Promise<AuditSink> {
189
- const provider = auditSinkModuleFor(sink).provides?.auditSink;
190
- if (!provider) throw new Error("the audit-sink module must provide the auditSink slot");
191
- return (await provider({ section } as never)) as AuditSink;
192
- }
193
-
194
- describe("the template's audit sink", () => {
195
- describe("createLoggerAuditSink — one event, one line, through the app's own JSON stream", () => {
196
- it("records the event through the injected logger", async () => {
197
- const logger = fakeLogger();
198
- const sink = createLoggerAuditSink(logger);
199
- const event: AuditEvent = {
200
- timestamp: new Date("2026-08-27T00:00:00Z"),
201
- type: "authorize.rejected",
202
- clientId: "c1",
203
- subject: "u1",
204
- details: { reason: "client_not_first_party" },
205
- };
206
-
207
- await sink.record(event);
208
-
209
- expect(logger.info).toHaveBeenCalledTimes(1);
210
- const [payload, message] = logger.info.mock.calls[0] as [Record<string, unknown>, string];
211
- // The event type doubles as the message so an operator can alert on
212
- // the name without a JSON path, matching how this template's other
213
- // structured events are named.
214
- expect(message).toBe("authorize.rejected");
215
- expect(payload.audit).toMatchObject({
216
- type: "authorize.rejected",
217
- clientId: "c1",
218
- subject: "u1",
219
- });
220
- });
221
-
222
- it("nests the event under `audit` so it cannot collide with pino's own keys", async () => {
223
- // `level`, `time`, `name` and `msg` belong to the log envelope. An
224
- // event spread at the top level would let a future audit field
225
- // overwrite one of them and corrupt the line for every consumer.
226
- const logger = fakeLogger();
227
- await createLoggerAuditSink(logger).record({
228
- timestamp: new Date("2026-08-27T00:00:00Z"),
229
- type: "rate_limit.unavailable",
230
- });
231
- const [payload] = logger.info.mock.calls[0] as [Record<string, unknown>];
232
- expect(Object.keys(payload)).toEqual(["audit"]);
233
- });
234
-
235
- it("reports the sink kind it was registered under", () => {
236
- expect(createLoggerAuditSink(fakeLogger()).kind).toBe("logger");
237
- });
238
- });
239
-
240
- describe("the audit trail is not gated by logging.level", () => {
241
- it("keeps the audit logger at info while the app logger is silenced", () => {
242
- // An audit trail is evidence, not diagnostics. `LOGGING_LEVEL=warn` is an
243
- // ordinary production setting and `silent` is a legitimate one;
244
- // neither may silently drop audit events.
245
- const appLogger = createAppLogger({ level: "silent" });
246
- const auditLogger = createAuditLogger();
247
- expect((appLogger as unknown as { level: string }).level).toBe("silent");
248
- expect((auditLogger as unknown as { level: string }).level).toBe("info");
249
- });
250
-
251
- it("names the audit stream so it is separable from application logs", () => {
252
- const bindings = (
253
- createAuditLogger() as unknown as { bindings(): Record<string, unknown> }
254
- ).bindings();
255
- expect(bindings.name).toBe("audit");
256
- });
257
- });
258
-
259
- describe("the audit-sink module — builds the sink adapters.auditSink selects", () => {
260
- it("builds the logger sink for the shipped selection", async () => {
261
- expect((await resolveSink(baseConfig.adapters.auditSink)).kind).toBe("logger");
262
- });
263
-
264
- it("builds core's built-in console sink when selected", async () => {
265
- expect((await resolveSink("console")).kind).toBe("console");
266
- });
267
-
268
- it("builds a sink with no audit-sink section at all", async () => {
269
- expect((await resolveSink("logger", undefined)).kind).toBe("logger");
270
- });
271
-
272
- it("refuses a sink no builder is registered under, naming it", async () => {
273
- // There is no "none" sink. An operator who selects one gets a boot
274
- // failure naming the sinks that exist, not a silent deployment with
275
- // no audit trail.
276
- await expect(resolveSink("none")).rejects.toThrow(/none/);
277
- });
278
- });
279
-
280
- describe("buildModules — the slot is filled in the manifest operators deploy", () => {
281
- it("wires exactly one auditSink provider", () => {
282
- const modules = buildModules(baseConfig, {
283
- keyStoreModule: testKeyStoreModule,
284
- repositoriesModule: testRepositoriesModule,
285
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
286
- });
287
- const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes("auditSink"));
288
- expect(providers).toHaveLength(1);
289
- expect(providers[0]?.name).toBe("audit-sink");
290
- });
291
-
292
- it("the shipped reference.conf selects a sink, and never 'none'", () => {
293
- const conf = readFileSync(new URL("../../config/reference.conf", import.meta.url), "utf8");
294
- expect(conf).toMatch(/adapters\s*\{[\s\S]*?auditSink\s*=\s*"logger"/);
295
- expect(conf).not.toMatch(/auditSink\s*=\s*"none"/);
296
- });
297
- });
298
-
299
- describe("end to end: an OAuth failure reaches the sink", () => {
300
- let handleRef: { dispose(): Promise<void> } | undefined;
301
-
302
- afterEach(async () => {
303
- await handleRef?.dispose();
304
- handleRef = undefined;
305
- });
306
-
307
- it("records token.issued.failure for an unsupported grant_type", async () => {
308
- // `emitAuditEvent` records nothing when the slot is empty.
309
- const logger = fakeLogger();
310
- const spyAuditModule = defineModule({
311
- name: "audit-sink",
312
- provides: { auditSink: () => createLoggerAuditSink(logger) },
313
- });
314
- const handle = await createApp({
315
- modules: buildModules(baseConfig, {
316
- keyStoreModule: testKeyStoreModule,
317
- repositoriesModule: testRepositoriesModule,
318
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
319
- auditSinkModule: spyAuditModule,
320
- }),
321
- bootstrapComponents: { config: baseConfig, pathResolver: (s) => s },
322
- });
323
- handleRef = handle;
324
-
325
- const app = express();
326
- app.use(handle.router);
327
- const res = await request(app)
328
- .post("/oauth/token")
329
- .set("Authorization", BASIC_AUTH)
330
- .type("form")
331
- .send({ grant_type: "definitely-not-a-grant" });
332
-
333
- expect(res.status).toBe(400);
334
- // `emitAuditEvent` is fire-and-forget; let the detached promise settle.
335
- await new Promise((r) => setImmediate(r));
336
- const types = logger.info.mock.calls.map(
337
- (call) => (call[0] as { audit: AuditEvent }).audit.type,
338
- );
339
- expect(types).toContain("token.issued.failure");
340
- });
341
-
342
- it("boots the shipped manifest with its own audit sink wired", async () => {
343
- const handle = await createApp({
344
- modules: buildModules(baseConfig, {
345
- keyStoreModule: testKeyStoreModule,
346
- repositoriesModule: testRepositoriesModule,
347
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
348
- }),
349
- bootstrapComponents: { config: baseConfig, pathResolver: (s) => s },
350
- });
351
- handleRef = handle;
352
- expect(handle).toBeDefined();
353
- });
354
- });
355
- });
@@ -1,64 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- /**
18
- * A relying party's back-channel logout endpoint on loopback, for the
19
- * template's tests: it answers every POST with one status and keeps each
20
- * `logout_token` it was sent. The composition reaches it only with its host
21
- * in `core.outbound.internalHosts` (`withLoopbackRelyingParties`).
22
- */
23
-
24
- import { createServer } from "node:http";
25
- import type { AddressInfo } from "node:net";
26
- import type { AppConfig } from "@o3co/auth-provider-core";
27
- import { withOutbound } from "@o3co/auth-provider-core/testing";
28
-
29
- export interface BackchannelPeer {
30
- /** The endpoint, `http://127.0.0.1:<port>/logout`. */
31
- readonly uri: string;
32
- /** Each `logout_token` posted to it, in order. */
33
- readonly tokens: readonly string[];
34
- close(): Promise<void>;
35
- }
36
-
37
- /** A loopback back-channel endpoint answering `status` to every POST. */
38
- export async function backchannelPeer(status = 200): Promise<BackchannelPeer> {
39
- const tokens: string[] = [];
40
- const server = createServer((req, res) => {
41
- let body = "";
42
- req.setEncoding("utf8");
43
- req.on("data", (chunk: string) => {
44
- body += chunk;
45
- });
46
- req.on("end", () => {
47
- const token = new URLSearchParams(body).get("logout_token");
48
- if (token !== null) tokens.push(token);
49
- res.writeHead(status);
50
- res.end();
51
- });
52
- });
53
- await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
54
- const { port } = server.address() as AddressInfo;
55
- return {
56
- uri: `http://127.0.0.1:${port}/logout`,
57
- tokens,
58
- close: () => new Promise<void>((resolve) => server.close(() => resolve())),
59
- };
60
- }
61
-
62
- /** `config` with loopback listed in `core.outbound.internalHosts`, so a `BackchannelPeer` is reachable. */
63
- export const withLoopbackRelyingParties = (config: AppConfig): AppConfig =>
64
- withOutbound(config, { internalHosts: ["127.0.0.1"] }) as AppConfig;
@@ -1,157 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- /**
18
- * Core's notices about configuration nothing loaded reads, through the
19
- * template's own reading: the template hands boot the configuration's
20
- * defaults (`configDefaultsFor`), and core alone decides what is named. A
21
- * section the template's `reference.conf` sets for a module the composition
22
- * does not load is silent while it is as that file sets it, and
23
- * `config_sections_not_loaded` once the operator's layer or the environment
24
- * changes it; a section no loaded package sets is `config_sections_ignored`;
25
- * a renamed variable no loaded module declares is
26
- * `environment_variables_not_applied`.
27
- */
28
-
29
- import { fileURLToPath } from "node:url";
30
- import { moduleReferences } from "@o3co/auth-provider-core";
31
- import { parseFile } from "@o3co/ts.hocon";
32
- import { afterEach, describe, expect, it, vi } from "vitest";
33
- import { configDefaultsFor } from "#/configPath.mjs";
34
- import {
35
- type ComposeOptions,
36
- type Composition,
37
- compose,
38
- composedModules,
39
- type RecordingLogger,
40
- resolveConfig,
41
- SINGLE_ENV,
42
- } from "./all-modules-composition.fixture.mjs";
43
-
44
- let current: Composition | undefined;
45
-
46
- afterEach(async () => {
47
- await current?.handle.dispose();
48
- current = undefined;
49
- vi.unstubAllEnvs();
50
- });
51
-
52
- const boot = async (options: ComposeOptions): Promise<Composition> => {
53
- current = await compose(options);
54
- return current;
55
- };
56
-
57
- /** What each warn line named `event` names: its `sections` or `variables`. */
58
- const named = (logger: RecordingLogger, event: string): unknown[] =>
59
- logger.lines
60
- .filter((line) => line.level === "warn" && line.args[1] === event)
61
- .map((line) => line.args[0]);
62
-
63
- /** `redis-clients` is the template's module, which no store on Redis loads under SINGLE_ENV. */
64
- const NOT_LOADED = "redis-clients";
65
-
66
- describe("a section the template's reference.conf sets for a module the composition does not load", () => {
67
- it("is handed to boot and named by nothing while it is as that file sets it", async () => {
68
- const { logger, resolved, modules } = await boot({ env: SINGLE_ENV });
69
- expect(modules.map((m) => m.name)).not.toContain(NOT_LOADED);
70
- expect(resolved).toHaveProperty([NOT_LOADED]);
71
- expect(named(logger, "config_sections_not_loaded")).toEqual([]);
72
- expect(named(logger, "config_sections_ignored")).toEqual([]);
73
- });
74
-
75
- it("is config_sections_not_loaded once the operator's layer changes it", async () => {
76
- const { logger } = await boot({
77
- env: SINGLE_ENV,
78
- operatorHocon: `${NOT_LOADED}.url = "redis://elsewhere.test:6379"\n`,
79
- });
80
- expect(named(logger, "config_sections_not_loaded")).toEqual([{ sections: [NOT_LOADED] }]);
81
- expect(named(logger, "config_sections_ignored")).toEqual([]);
82
- });
83
-
84
- it("is config_sections_not_loaded once the environment changes it, and the variable environment_variables_not_applied", async () => {
85
- const { logger } = await boot({
86
- env: { ...SINGLE_ENV, REDIS_CLIENTS_URL: "redis://elsewhere.test:6379" },
87
- });
88
- expect(named(logger, "config_sections_not_loaded")).toEqual([{ sections: [NOT_LOADED] }]);
89
- expect(named(logger, "config_sections_ignored")).toEqual([]);
90
- expect(named(logger, "environment_variables_not_applied")).toEqual([
91
- { variables: ["REDIS_CLIENTS_URL"] },
92
- ]);
93
- });
94
- });
95
-
96
- describe("the limiter sections the template's reference.conf writes for both limiters", () => {
97
- it.each(["memory", "redis"] as const)(
98
- "are named by nothing with the %s limiter wired",
99
- async (rateLimiter) => {
100
- const { logger, modules } = await boot({
101
- env: { ...SINGLE_ENV, ADAPTERS_RATE_LIMITER: rateLimiter },
102
- });
103
- expect(modules.map((m) => m.name)).toContain(
104
- rateLimiter === "redis" ? "redis-rate-limiter" : "core-rate-limiter-memory",
105
- );
106
- expect(named(logger, "config_sections_not_loaded")).toEqual([]);
107
- expect(named(logger, "config_sections_ignored")).toEqual([]);
108
- },
109
- );
110
- });
111
-
112
- describe("a section no loaded package's reference.conf sets", () => {
113
- it("is config_sections_ignored: a misspelt section name", async () => {
114
- const { logger } = await boot({
115
- env: SINGLE_ENV,
116
- operatorHocon: 'redis-client.url = "redis://elsewhere.test:6379"\n',
117
- });
118
- expect(named(logger, "config_sections_ignored")).toEqual([{ sections: ["redis-client"] }]);
119
- expect(named(logger, "config_sections_not_loaded")).toEqual([]);
120
- });
121
- });
122
-
123
- describe("a renamed variable a loaded package captures and no loaded module declares", () => {
124
- it("is environment_variables_not_applied: RATE_LIMIT_FAIL_MODE with the Redis package loaded but not its rate limiter", async () => {
125
- const { logger, modules } = await boot({
126
- env: { ...SINGLE_ENV, ADAPTERS_CODE_REPOSITORY: "redis", RATE_LIMIT_FAIL_MODE: "closed" },
127
- });
128
- expect(modules.map((m) => m.name)).not.toContain("redis-rate-limiter");
129
- expect(named(logger, "environment_variables_not_applied")).toEqual([
130
- { variables: ["RATE_LIMIT_FAIL_MODE"] },
131
- ]);
132
- expect(named(logger, "config_sections_not_loaded")).toEqual([]);
133
- expect(named(logger, "config_sections_ignored")).toEqual([]);
134
- });
135
- });
136
-
137
- describe("configDefaultsFor", () => {
138
- const modules = composedModules(resolveConfig(SINGLE_ENV));
139
-
140
- it("is every loaded package's reference.conf, core's last, resolved with no environment", () => {
141
- const references = moduleReferences(modules).map((reference) =>
142
- parseFile(fileURLToPath(reference), { env: {} }),
143
- );
144
- const expected = references
145
- .slice(1)
146
- .reduce((config, next) => config.withFallback(next), references[0]);
147
- expect(configDefaultsFor(modules)).toEqual(expected?.toObject());
148
- });
149
-
150
- it("takes nothing from the process's environment", () => {
151
- vi.stubEnv("REDIS_CLIENTS_URL", "redis://operator.test:6379");
152
- expect(configDefaultsFor(modules)[NOT_LOADED]).toEqual({
153
- url: "redis://localhost:6379",
154
- assumeNoEviction: false,
155
- });
156
- });
157
- });
@@ -1,69 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- import { existsSync } from "node:fs";
18
- import { isAbsolute } from "node:path";
19
- import { fileURLToPath } from "node:url";
20
- import { coreReference } from "@o3co/auth-provider-core";
21
- import { describe, expect, it } from "vitest";
22
- import { resolveConfigPaths } from "../configPath.mjs";
23
-
24
- describe("resolveConfigPaths", () => {
25
- it("accepts a configDirPath with a trailing slash (regression: fileURLToPath preserves trailing /)", () => {
26
- const { applicationConfPath, envConfPath } = resolveConfigPaths(
27
- "/home/node/templates/standalone/config/",
28
- "production",
29
- );
30
- expect(applicationConfPath).toBe("/home/node/templates/standalone/config/application.conf");
31
- expect(envConfPath).toBe("/home/node/templates/standalone/config/production.conf");
32
- });
33
-
34
- it("accepts a configDirPath without a trailing slash", () => {
35
- const { applicationConfPath, envConfPath } = resolveConfigPaths(
36
- "/home/node/templates/standalone/config",
37
- "development",
38
- );
39
- expect(applicationConfPath).toBe("/home/node/templates/standalone/config/application.conf");
40
- expect(envConfPath).toBe("/home/node/templates/standalone/config/development.conf");
41
- });
42
-
43
- it("rejects env names that resolve outside configDirPath (path traversal)", () => {
44
- expect(() =>
45
- resolveConfigPaths("/home/node/templates/standalone/config/", "../secrets"),
46
- ).toThrow(/resolves outside/);
47
- });
48
-
49
- it("rejects env names containing a path separator", () => {
50
- expect(() =>
51
- resolveConfigPaths("/home/node/templates/standalone/config/", "nested/env"),
52
- ).toThrow(/resolves outside/);
53
- });
54
- });
55
-
56
- describe("core's reference.conf, as the template layers it", () => {
57
- // `coreReference()` names the file `@o3co/auth-provider-core` exports as
58
- // `./reference.conf`: the one a composition root that installed the
59
- // package would resolve, and the bottom of every layering the template
60
- // makes (`readSwitches`, `resolveForBoot`).
61
- it("is the file the package exports as ./reference.conf, and it exists", () => {
62
- const path = fileURLToPath(coreReference());
63
- expect(isAbsolute(path)).toBe(true);
64
- expect(existsSync(path)).toBe(true);
65
- expect(path).toBe(
66
- fileURLToPath(import.meta.resolve("@o3co/auth-provider-core/reference.conf")),
67
- );
68
- });
69
- });