@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,217 +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 template with core's session lifecycle module: a close whose
19
- * relying-party notice fails stays pending, its user session still there,
20
- * and from its commit no token reader treats the session as live —
21
- * introspection, userinfo and the session grant all refuse it.
22
- */
23
-
24
- import type { SessionLifecycle, UserSessionStore } from "@o3co/auth-provider-core";
25
- import request from "supertest";
26
- import { afterEach, describe, expect, it, vi } from "vitest";
27
- import {
28
- authorize,
29
- basic,
30
- codeFrom,
31
- compose,
32
- login,
33
- redeem,
34
- WEB,
35
- } from "./all-modules-composition.fixture.mjs";
36
- import { backchannelPeer, withLoopbackRelyingParties } from "./backchannel-peer.fixture.mjs";
37
-
38
- afterEach(() => {
39
- vi.restoreAllMocks();
40
- });
41
-
42
- /** A JWT's claims, unverified. */
43
- const claimsOf = (token: string): Record<string, unknown> =>
44
- JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
45
-
46
- describe("a session whose close is pending on a failed relying-party notice", () => {
47
- it("is refused by introspection, userinfo and the session grant, its user session still there", async () => {
48
- const peer = await backchannelPeer(503);
49
- const { app, handle } = await compose({
50
- config: withLoopbackRelyingParties,
51
- extraClients: {
52
- "rp-down": {
53
- tokenEndpointAuthMethod: "client_secret_basic",
54
- clientSecret: "rp-down-secret-long-enough",
55
- allowedRedirectUris: ["https://rp-down.test/cb"],
56
- allowedScopes: ["openid"],
57
- allowedGrantTypes: ["authorization_code"],
58
- backchannelLogoutUri: peer.uri,
59
- },
60
- },
61
- });
62
- try {
63
- const components = handle.components as Record<string, unknown>;
64
- const lifecycle = components.sessionLifecycle as SessionLifecycle;
65
- const sessions = components.userSessionStore as UserSessionStore;
66
- const { cookies } = await login(app);
67
- const sessionGrant = () =>
68
- request(app)
69
- .post("/oauth/token")
70
- .set("Authorization", basic(WEB))
71
- .set("Cookie", cookies)
72
- .type("form")
73
- .send({ grant_type: "session", scope: "openid profile" });
74
- const issued = await sessionGrant();
75
- expect(issued.status).toBe(200);
76
- const accessToken = issued.body.access_token as string;
77
- const sid = String(claimsOf(accessToken).sid);
78
- const introspect = () =>
79
- request(app)
80
- .post("/oauth/introspect")
81
- .set("Authorization", basic(WEB))
82
- .type("form")
83
- .send({ token: accessToken });
84
- const userinfo = () =>
85
- request(app).get("/oauth/userinfo").set("Authorization", `Bearer ${accessToken}`);
86
- expect((await introspect()).body.active).toBe(true);
87
- expect((await userinfo()).status).toBe(200);
88
-
89
- // A relying party joins with its family; its back-channel endpoint is
90
- // down, so the close commits with its notice pending.
91
- expect(
92
- await lifecycle.join(sid, {
93
- rp: {
94
- clientId: "rp-down",
95
- backchannelLogoutUri: peer.uri,
96
- backchannelLogoutSessionRequired: true,
97
- frontchannelLogoutUri: undefined,
98
- frontchannelLogoutSessionRequired: undefined,
99
- registeredAt: new Date(),
100
- },
101
- familyId: "family-down",
102
- }),
103
- ).toEqual({ outcome: "joined" });
104
- expect((await lifecycle.close(sid, "rp_logout")).outcome).toBe("pending");
105
- expect(peer.tokens.length).toBeGreaterThan(0);
106
- expect(await sessions.get(sid)).not.toBeNull();
107
-
108
- const afterIntrospect = await introspect();
109
- expect(afterIntrospect.status).toBe(200);
110
- expect(afterIntrospect.body).toEqual({ active: false });
111
-
112
- const afterUserinfo = await userinfo();
113
- expect(afterUserinfo.status).toBe(401);
114
- expect(afterUserinfo.body.error_description).toBe("session_invalid");
115
-
116
- const again = await sessionGrant();
117
- expect(again.status).toBe(400);
118
- expect(again.body).toMatchObject({
119
- error: "invalid_grant",
120
- error_description: "session_invalid",
121
- });
122
- } finally {
123
- await handle.dispose();
124
- await peer.close();
125
- }
126
- });
127
-
128
- it("an RP-initiated logout whose notice fails answers success, and its session is refused by introspection, userinfo and the session grant", async () => {
129
- const peer = await backchannelPeer(503);
130
- const { app, handle } = await compose({
131
- config: withLoopbackRelyingParties,
132
- extraClients: {
133
- "rp-down": {
134
- tokenEndpointAuthMethod: "client_secret_basic",
135
- clientSecret: "rp-down-secret-long-enough",
136
- allowedRedirectUris: ["https://rp-down.test/cb"],
137
- allowedScopes: ["openid"],
138
- allowedGrantTypes: ["authorization_code"],
139
- backchannelLogoutUri: peer.uri,
140
- },
141
- },
142
- });
143
- try {
144
- const components = handle.components as Record<string, unknown>;
145
- const lifecycle = components.sessionLifecycle as SessionLifecycle;
146
- const sessions = components.userSessionStore as UserSessionStore;
147
- const { cookies } = await login(app);
148
- // The code exchange joins the session; its id_token is the logout's hint.
149
- const exchanged = await redeem(app, codeFrom(await authorize(app, cookies)));
150
- expect(exchanged.status).toBe(200);
151
- const idToken = exchanged.body.id_token as string;
152
- const sid = String(claimsOf(idToken).sid);
153
- const sessionGrant = () =>
154
- request(app)
155
- .post("/oauth/token")
156
- .set("Authorization", basic(WEB))
157
- .set("Cookie", cookies)
158
- .type("form")
159
- .send({ grant_type: "session", scope: "openid profile" });
160
- const issued = await sessionGrant();
161
- expect(issued.status).toBe(200);
162
- // A session-grant token carries the session and no family.
163
- const accessToken = issued.body.access_token as string;
164
- expect(claimsOf(accessToken)).toMatchObject({ sid });
165
- expect(claimsOf(accessToken).family_id).toBeUndefined();
166
-
167
- // A relying party whose back-channel endpoint is down joins the session.
168
- expect(
169
- await lifecycle.join(sid, {
170
- rp: {
171
- clientId: "rp-down",
172
- backchannelLogoutUri: peer.uri,
173
- backchannelLogoutSessionRequired: true,
174
- frontchannelLogoutUri: undefined,
175
- frontchannelLogoutSessionRequired: undefined,
176
- registeredAt: new Date(),
177
- },
178
- }),
179
- ).toEqual({ outcome: "joined" });
180
-
181
- const logout = await request(app)
182
- .post("/oauth/logout")
183
- .type("form")
184
- .send({ id_token_hint: idToken });
185
-
186
- // The close committed with the notice pending: the logout succeeded.
187
- expect(logout.status).toBe(200);
188
- expect(logout.body).toEqual({ logged_out: true });
189
- expect(peer.tokens.length).toBeGreaterThan(0);
190
- expect(await sessions.get(sid)).not.toBeNull();
191
-
192
- const introspected = await request(app)
193
- .post("/oauth/introspect")
194
- .set("Authorization", basic(WEB))
195
- .type("form")
196
- .send({ token: accessToken });
197
- expect(introspected.status).toBe(200);
198
- expect(introspected.body).toEqual({ active: false });
199
-
200
- const userinfo = await request(app)
201
- .get("/oauth/userinfo")
202
- .set("Authorization", `Bearer ${accessToken}`);
203
- expect(userinfo.status).toBe(401);
204
- expect(userinfo.body.error_description).toBe("session_invalid");
205
-
206
- const again = await sessionGrant();
207
- expect(again.status).toBe(400);
208
- expect(again.body).toMatchObject({
209
- error: "invalid_grant",
210
- error_description: "session_invalid",
211
- });
212
- } finally {
213
- await handle.dispose();
214
- await peer.close();
215
- }
216
- });
217
- });
@@ -1,121 +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
- * Starting the listener: one object-first `server_listening` line once the
19
- * socket is bound, and none for a port that was never bound. `listen` wires
20
- * the server's events itself rather than trust `app.listen`, whose Express 5
21
- * callback is handed the bind `error` as well as `listening`.
22
- */
23
-
24
- import { createServer, type Server } from "node:http";
25
- import type { AddressInfo } from "node:net";
26
- import type { Logger } from "@o3co/auth-provider-core";
27
- import express from "express";
28
- import { afterEach, describe, expect, it } from "vitest";
29
- import { listen } from "#/listen.mjs";
30
-
31
- /** A logger that records what each level is handed. */
32
- function recordingLogger(): { logger: Logger; calls: Array<{ level: string; args: unknown[] }> } {
33
- const calls: Array<{ level: string; args: unknown[] }> = [];
34
- const record =
35
- (level: string) =>
36
- (...args: unknown[]): void => {
37
- calls.push({ level, args });
38
- };
39
- const logger: Logger = {
40
- trace: record("trace"),
41
- debug: record("debug"),
42
- info: record("info"),
43
- warn: record("warn"),
44
- error: record("error"),
45
- fatal: record("fatal"),
46
- child: () => logger,
47
- };
48
- return { logger, calls };
49
- }
50
-
51
- const opened: Server[] = [];
52
-
53
- afterEach(async () => {
54
- await Promise.all(
55
- opened.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))),
56
- );
57
- });
58
-
59
- describe("listen", () => {
60
- it("says server_listening once, object-first, with the port the socket holds", async () => {
61
- const { logger, calls } = recordingLogger();
62
- const server = await listen(express(), 0, logger);
63
- opened.push(server);
64
- const { port } = server.address() as AddressInfo;
65
- expect(port).toBeGreaterThan(0);
66
- expect(calls).toEqual([{ level: "info", args: [{ port }, "server_listening"] }]);
67
- });
68
-
69
- it("logs a server error after the bind — server_error, the projection — rather than swallowing it", async () => {
70
- // Every `error` after the bind (accept EMFILE) is logged: a spent
71
- // once-listener would lose the first, and with no listener left the
72
- // second would be thrown out of the process.
73
- const { logger, calls } = recordingLogger();
74
- const server = await listen(express(), 0, logger);
75
- opened.push(server);
76
- calls.length = 0;
77
-
78
- const failure = Object.assign(new Error("accept EMFILE"), { code: "EMFILE" });
79
- server.emit("error", failure);
80
- server.emit("error", failure);
81
-
82
- expect(calls).toEqual([
83
- {
84
- level: "error",
85
- args: [{ err: expect.objectContaining({ name: "Error", code: "EMFILE" }) }, "server_error"],
86
- },
87
- {
88
- level: "error",
89
- args: [{ err: expect.objectContaining({ name: "Error", code: "EMFILE" }) }, "server_error"],
90
- },
91
- ]);
92
- const line = calls[0]?.args[0] as { err: unknown };
93
- expect(line.err).not.toBeInstanceOf(Error);
94
- });
95
-
96
- it("leaves the bound server one error listener, server_error's: the bind's own is removed", async () => {
97
- // The listener that turns a bind failure into a rejection has done its
98
- // job once the socket is bound. Left attached, it would take every
99
- // later error beside server_error's — a callback already spent.
100
- const { logger } = recordingLogger();
101
- const server = await listen(express(), 0, logger);
102
- opened.push(server);
103
-
104
- expect(server.listenerCount("error")).toBe(1);
105
- // And nothing of `listen`'s own left on `listening`: an http.Server
106
- // keeps one there itself (its connection tracking).
107
- expect(server.listenerCount("listening")).toBe(createServer().listenerCount("listening"));
108
- });
109
-
110
- it("rejects with the server's error for a port already bound, and announces nothing", async () => {
111
- const holder = createServer();
112
- // Every interface, as `server.listen(port)` binds, so the two collide.
113
- await new Promise<void>((resolve) => holder.listen(0, resolve));
114
- opened.push(holder);
115
- const { port } = holder.address() as AddressInfo;
116
-
117
- const { logger, calls } = recordingLogger();
118
- await expect(listen(express(), port, logger)).rejects.toMatchObject({ code: "EADDRINUSE" });
119
- expect(calls).toEqual([]);
120
- });
121
- });
@@ -1,77 +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
- * What the template's own logger writes for a projected error — the line on
19
- * stdout, from `createAppLogger` in a process of its own, as the service
20
- * writes it. Every field of core's `loggableError` projection must reach
21
- * it, at every level of the cause chain: the logger's `err` serializer must
22
- * not take the projection for an Error and fold its causes into one message.
23
- */
24
-
25
- import { execFileSync } from "node:child_process";
26
- import { createRequire } from "node:module";
27
- import { fileURLToPath } from "node:url";
28
- import { describe, expect, it } from "vitest";
29
-
30
- const templateRoot = fileURLToPath(new URL("../..", import.meta.url));
31
- const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
32
-
33
- /**
34
- * A request failure caused by a Store failure caused by a parse failure,
35
- * logged through `createAppLogger` in a child process; the line it wrote.
36
- */
37
- const SCRIPT = `
38
- const { createAppLogger } = await import("./src/logger.mts");
39
- const { loggableError } = await import("@o3co/auth-provider-core");
40
- let parse;
41
- try { JSON.parse('{"a":1,'); } catch (err) { parse = err; }
42
- const store = Object.assign(new Error("the Store could not be read", { cause: parse }), {
43
- name: "StoreTransportError", code: "ECONNREFUSED", reason: "unreachable", storeStatus: 503,
44
- });
45
- const failed = Object.assign(new Error("request failed", { cause: store }), {
46
- type: "upstream.failed", status: 502,
47
- });
48
- createAppLogger({ level: "info" }).error({ err: loggableError(failed) }, "unhandled_request_error");
49
- `;
50
-
51
- describe("createAppLogger writes a projected error whole", () => {
52
- it("keeps every field of every level of the cause chain on the line", () => {
53
- const stdout = execFileSync(
54
- process.execPath,
55
- [tsxCli, "--input-type=module", "--eval", SCRIPT],
56
- { cwd: templateRoot, encoding: "utf8" },
57
- );
58
- const line = JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}") as {
59
- msg?: string;
60
- err?: Record<string, unknown>;
61
- };
62
-
63
- expect(line.msg).toBe("unhandled_request_error");
64
- expect(line.err).toMatchObject({
65
- name: "Error",
66
- type: "upstream.failed",
67
- status: 502,
68
- cause: {
69
- name: "StoreTransportError",
70
- code: "ECONNREFUSED",
71
- reason: "unreachable",
72
- storeStatus: 503,
73
- cause: { name: "SyntaxError", position: 7 },
74
- },
75
- });
76
- });
77
- });
@@ -1,113 +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 template's two logout endpoints and the session cookie: the session
19
- * store module expires the cookie of a session destroyed during the request,
20
- * whichever route destroyed it, so `POST /oauth/logout` — whose package knows
21
- * no cookie name — and `POST /session/logout` both answer with the cookie
22
- * expired, with the attributes the login set it with.
23
- */
24
-
25
- import type { SessionCookiePolicy } from "@o3co/auth-provider-core";
26
- import request from "supertest";
27
- import { describe, expect, it } from "vitest";
28
- import { authorize, codeFrom, compose, login, redeem } from "./all-modules-composition.fixture.mjs";
29
-
30
- /** The `Set-Cookie` lines among `lines` for the cookie `name`. */
31
- const linesNamed = (lines: readonly string[], name: string): string[] =>
32
- lines.filter((line) => line.startsWith(`${name}=`));
33
-
34
- /** A response's `Set-Cookie` lines. */
35
- const setCookies = (res: request.Response): string[] =>
36
- ([] as string[]).concat(res.headers["set-cookie"] ?? []);
37
-
38
- /** A `Set-Cookie` line's attributes, lower-cased, with its value and expiry left out. */
39
- const attributesOf = (line: string): string[] =>
40
- line
41
- .split(";")
42
- .slice(1)
43
- .map((part) => part.trim().toLowerCase())
44
- .filter((part) => !part.startsWith("expires=") && !part.startsWith("max-age="))
45
- .sort();
46
-
47
- /** The CSRF token a login reissued, read off its double-submit cookie. */
48
- const csrfTokenOf = (cookies: readonly string[]): string => {
49
- const cookie = cookies.find((c) => /^[^=]*\.csrf=/.test(c));
50
- if (cookie === undefined) throw new Error("the login reissued no CSRF cookie");
51
- return decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1).split(";")[0] ?? "");
52
- };
53
-
54
- /**
55
- * The one `Set-Cookie` line of `res` for the session cookie, checked to
56
- * expire it with the attributes of `setLine`, the line the login set it with.
57
- */
58
- function expectExpired(res: request.Response, name: string, setLine: string): void {
59
- const lines = linesNamed(setCookies(res), name);
60
- expect(lines).toHaveLength(1);
61
- const [line] = lines as [string];
62
- expect(line.startsWith(`${name}=;`)).toBe(true);
63
- expect(line).toContain("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
64
- expect(attributesOf(line)).toEqual(attributesOf(setLine));
65
- }
66
-
67
- describe("the session cookie at the template's logout endpoints", () => {
68
- it("POST /oauth/logout expires the session cookie, with the attributes the login set it with", async () => {
69
- const { app, handle } = await compose();
70
- try {
71
- const policy = handle.components.sessionCookiePolicy as SessionCookiePolicy;
72
- const { res: signedIn, cookies } = await login(app);
73
- expect(signedIn.status).toBe(200);
74
- const [setLine] = linesNamed(cookies, policy.name);
75
- expect(setLine).toBeDefined();
76
- const tokens = await redeem(app, codeFrom(await authorize(app, cookies)));
77
- expect(tokens.status).toBe(200);
78
-
79
- const res = await request(app)
80
- .post("/oauth/logout")
81
- .set("Cookie", cookies)
82
- .type("form")
83
- .send({ id_token_hint: tokens.body.id_token as string });
84
-
85
- expect(res.status).toBe(200);
86
- expect(res.body).toEqual({ logged_out: true });
87
- expectExpired(res, policy.name, setLine as string);
88
- } finally {
89
- await handle.dispose();
90
- }
91
- });
92
-
93
- it("POST /session/logout expires the session cookie, with the attributes the login set it with", async () => {
94
- const { app, handle } = await compose();
95
- try {
96
- const policy = handle.components.sessionCookiePolicy as SessionCookiePolicy;
97
- const { res: signedIn, cookies } = await login(app);
98
- expect(signedIn.status).toBe(200);
99
- const [setLine] = linesNamed(cookies, policy.name);
100
- expect(setLine).toBeDefined();
101
-
102
- const res = await request(app)
103
- .post("/session/logout")
104
- .set("Cookie", cookies)
105
- .set("x-csrf-token", csrfTokenOf(cookies));
106
-
107
- expect(res.status).toBe(200);
108
- expectExpired(res, policy.name, setLine as string);
109
- } finally {
110
- await handle.dispose();
111
- }
112
- });
113
- });
@@ -1,134 +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 mail sender the template installs behind core's `mailSender` slot,
19
- * both from `@o3co/auth-provider-standard`: the development sender where the
20
- * configuration was selected as development — the name `app.mts` defaults
21
- * to — the SMTP sender's module under any other name, and none when
22
- * `buildModules` is told no name. A deployment's own sender modules, passed
23
- * as `mailSenderModules`, replace that choice under every name. Nothing in
24
- * the template reads the slot: this is the wiring alone.
25
- */
26
-
27
- import { defineModule, type MailSender, type Module } from "@o3co/auth-provider-core";
28
- import {
29
- standardDevelopmentMailSenderModule,
30
- standardSmtpMailSenderModule,
31
- } from "@o3co/auth-provider-standard";
32
- import { afterEach, describe, expect, it } from "vitest";
33
- import { buildModules } from "#/buildModules.mjs";
34
- import {
35
- type Composition,
36
- compose,
37
- resolveConfig,
38
- SINGLE_ENV,
39
- } from "./all-modules-composition.fixture.mjs";
40
-
41
- const DEVELOPMENT = standardDevelopmentMailSenderModule({ environment: "development" }).name;
42
- const SMTP = standardSmtpMailSenderModule.name;
43
-
44
- /** A deployment's own sender, built at boot so the slot holds it with nothing reading it. */
45
- const OWN_SENDER: MailSender = {
46
- kind: "deployment-own",
47
- send: async () => ({ outcome: "delivered" }),
48
- };
49
- const ownSenderModule: Module = defineModule({
50
- name: "deployment-own-mail-sender",
51
- lifecycle: { mailSender: { eager: true } },
52
- provides: { mailSender: () => OWN_SENDER },
53
- });
54
- const OWN = ownSenderModule.name;
55
-
56
- /** The mail sender modules `buildModules` lists for `environment`. */
57
- const senders = (
58
- environment: string | undefined,
59
- mailSenderModules?: readonly Module[],
60
- ): string[] =>
61
- buildModules(resolveConfig(SINGLE_ENV), {
62
- ...(environment === undefined ? {} : { environment }),
63
- ...(mailSenderModules === undefined ? {} : { mailSenderModules }),
64
- })
65
- .map((module) => module.name)
66
- .filter((name) => name === DEVELOPMENT || name === SMTP || name === OWN);
67
-
68
- const ENVIRONMENTS = ["development", "production", "staging", "test", "Development"] as const;
69
-
70
- describe("the template's mail sender", () => {
71
- let current: Composition | undefined;
72
- afterEach(async () => {
73
- await current?.handle.dispose();
74
- current = undefined;
75
- });
76
-
77
- it("is the development sender in development, and none where no environment is named", () => {
78
- expect(senders("development")).toEqual([DEVELOPMENT]);
79
- expect(senders(undefined)).toEqual([]);
80
- });
81
-
82
- it("is the SMTP sender's module under any other environment", () => {
83
- for (const environment of ["production", "staging", "test", "Development"]) {
84
- expect(senders(environment), environment).toEqual([SMTP]);
85
- }
86
- });
87
-
88
- it("fills the slot with the development sender when the composition boots in development", async () => {
89
- current = await compose({ environment: "development" });
90
- expect((current.handle.components.mailSender as MailSender | undefined)?.kind).toBe(
91
- "standard-development",
92
- );
93
- });
94
-
95
- it("boots in production with the SMTP sender's module installed and no SMTP settings, building no sender where nothing reads the slot", async () => {
96
- current = await compose();
97
- expect(current.modules.map((module) => module.name)).toContain(SMTP);
98
- expect(current.handle.components.mailSender).toBeUndefined();
99
- });
100
-
101
- describe("given the deployment's own sender modules", () => {
102
- it("installs them in place of the bundled sender, under every environment name and none", () => {
103
- for (const environment of [...ENVIRONMENTS, undefined]) {
104
- expect(senders(environment, [ownSenderModule]), String(environment)).toEqual([OWN]);
105
- }
106
- });
107
-
108
- it("installs no sender at all when the list is empty", () => {
109
- for (const environment of [...ENVIRONMENTS, undefined]) {
110
- expect(senders(environment, []), String(environment)).toEqual([]);
111
- }
112
- });
113
-
114
- it.each(["development", "production"])(
115
- "fills the slot with the deployment's sender when the composition boots in %s",
116
- async (environment) => {
117
- current = await compose({ environment, mailSenderModules: [ownSenderModule] });
118
- const names = current.modules.map((module) => module.name);
119
- expect(names).toContain(OWN);
120
- expect(names).not.toContain(DEVELOPMENT);
121
- expect(names).not.toContain(SMTP);
122
- expect(current.handle.components.mailSender).toBe(OWN_SENDER);
123
- },
124
- );
125
-
126
- it("boots in production with no sender when the list is empty", async () => {
127
- current = await compose({ environment: "production", mailSenderModules: [] });
128
- const names = current.modules.map((module) => module.name);
129
- expect(names).not.toContain(DEVELOPMENT);
130
- expect(names).not.toContain(SMTP);
131
- expect(current.handle.components.mailSender).toBeUndefined();
132
- });
133
- });
134
- });