@stjoseph/prumo 0.5.1 → 0.6.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 (42) hide show
  1. package/.prumo-templates/api/auth.md +1 -1
  2. package/.prumo-templates/mcp/server.md +1 -1
  3. package/.prumo-templates/monorepo/layout.md +5 -1
  4. package/.prumo-templates/monorepo/tasks.md +5 -3
  5. package/.prumo-templates/social/sign-in.md +2 -1
  6. package/README.md +3 -1
  7. package/dist/add.js +224 -0
  8. package/dist/apps.js +205 -0
  9. package/dist/asker.js +37 -0
  10. package/dist/clean.js +3 -3
  11. package/dist/cli.js +51 -2
  12. package/dist/commands.js +10 -0
  13. package/dist/compose.js +86 -28
  14. package/dist/context.js +32 -5
  15. package/dist/database.js +3 -4
  16. package/dist/generate.js +26 -24
  17. package/dist/names.js +2 -1
  18. package/dist/next-steps.js +4 -3
  19. package/dist/questions.js +22 -3
  20. package/dist/workspace-yaml.js +91 -0
  21. package/package.json +1 -1
  22. package/templates/api/.env.example +1 -0
  23. package/templates/api/README.md +2 -1
  24. package/templates/api/scripts/ports.mjs +60 -45
  25. package/templates/api/src/infra/config/env.ts +8 -1
  26. package/templates/api/src/infra/http/controllers/oauth.controller.ts +2 -2
  27. package/templates/api/src/infra/http/controllers/social.controller.ts +29 -13
  28. package/templates/api/src/infra/http/hooks/origin.hook.ts +4 -4
  29. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +1 -1
  30. package/templates/api/test/infra/config/env.spec.ts +26 -0
  31. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +24 -0
  32. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +23 -1
  33. package/templates/api/test/support/setup.ts +1 -1
  34. package/templates/mobile/scripts/ports.mjs +60 -45
  35. package/templates/mobile/src/api-contract/auth.ts +15 -3
  36. package/templates/site/scripts/ports.mjs +60 -45
  37. package/templates/web/scripts/ports.mjs +60 -45
  38. package/templates/web/src/api-contract/auth.ts +15 -3
  39. package/templates/web/src/features/auth/social-buttons.tsx +2 -2
  40. package/templates/web/test/features/auth/social-buttons.spec.tsx +1 -0
  41. package/templates/workspace/README.md +2 -2
  42. package/templates/workspace/scripts/ports.mjs +60 -45
@@ -0,0 +1,91 @@
1
+ // pnpm-workspace.yaml as Prumo writes it: `catalog:`, `catalogs:` with a block per named catalog, and `allowBuilds:`,
2
+ // each a top-level key with its entries indented below it. An app that joins only adds to them: an entry already
3
+ // there is the workspace's, and its version wins, since a team that moved it did so on purpose.
4
+ const entryName = (line) => /^\s*'?([^'#\s][^':]*)'?\s*:/.exec(line)?.[1];
5
+ const indentOf = (line) => line.length - line.trimStart().length;
6
+ // The lines of a block's entries, from the line after its key to the first line indented no deeper than it.
7
+ function block(lines, key, indent, from = 0, to = lines.length) {
8
+ const start = lines.findIndex((line, index) => index >= from && index < to && line === `${' '.repeat(indent)}${key}:`);
9
+ if (start === -1) {
10
+ return undefined;
11
+ }
12
+ let end = start + 1;
13
+ while (end < to && (lines[end]?.trim() === '' || indentOf(lines[end] ?? '') > indent)) {
14
+ end += 1;
15
+ }
16
+ // Blank lines before the next key belong to the gap, not to the block.
17
+ while (end > start + 1 && lines[end - 1]?.trim() === '') {
18
+ end -= 1;
19
+ }
20
+ return { start, end };
21
+ }
22
+ function addEntries(lines, where, entries, indent) {
23
+ const present = new Set(lines
24
+ .slice(where.start + 1, where.end)
25
+ .map(entryName)
26
+ .filter((name) => name !== undefined));
27
+ const missing = Object.keys(entries)
28
+ .filter((name) => !present.has(name))
29
+ .sort()
30
+ .map((name) => `${' '.repeat(indent)}'${name}': ${entries[name]}`);
31
+ lines.splice(where.end, 0, ...missing);
32
+ }
33
+ export function addToCatalogs(text, catalogs) {
34
+ const lines = text.replace(/\n$/, '').split('\n');
35
+ const catalog = block(lines, 'catalog', 0);
36
+ if (catalog === undefined) {
37
+ lines.push('', 'catalog:');
38
+ addEntries(lines, { start: lines.length - 1, end: lines.length }, catalogs.default, 2);
39
+ }
40
+ else {
41
+ addEntries(lines, catalog, catalogs.default, 2);
42
+ }
43
+ for (const [name, entries] of Object.entries(catalogs.named)) {
44
+ let named = block(lines, 'catalogs', 0);
45
+ if (named === undefined) {
46
+ lines.push('', 'catalogs:');
47
+ named = { start: lines.length - 1, end: lines.length };
48
+ }
49
+ const own = block(lines, name, 2, named.start + 1, named.end);
50
+ if (own === undefined) {
51
+ lines.splice(named.end, 0, ` ${name}:`);
52
+ addEntries(lines, { start: named.end, end: named.end + 1 }, entries, 4);
53
+ }
54
+ else {
55
+ addEntries(lines, own, entries, 4);
56
+ }
57
+ }
58
+ return `${lines.join('\n')}\n`;
59
+ }
60
+ // Each approval arrives with the comment above it that says why, as the template wrote them.
61
+ export function addApprovals(text, approvals) {
62
+ const lines = text.replace(/\n$/, '').split('\n');
63
+ let builds = block(lines, 'allowBuilds', 0);
64
+ if (builds === undefined) {
65
+ if (approvals.length === 0) {
66
+ return text;
67
+ }
68
+ lines.push('', 'allowBuilds:');
69
+ builds = { start: lines.length - 1, end: lines.length };
70
+ }
71
+ const present = new Set(lines
72
+ .slice(builds.start + 1, builds.end)
73
+ .map(entryName)
74
+ .filter((name) => name !== undefined));
75
+ const added = [];
76
+ let comments = [];
77
+ for (const line of approvals) {
78
+ if (line.trimStart().startsWith('#')) {
79
+ comments.push(line);
80
+ continue;
81
+ }
82
+ const name = entryName(line);
83
+ if (name !== undefined && !present.has(name)) {
84
+ added.push(...comments, line);
85
+ present.add(name);
86
+ }
87
+ comments = [];
88
+ }
89
+ lines.splice(builds.end, 0, ...added);
90
+ return `${lines.join('\n')}\n`;
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stjoseph/prumo",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Scaffolds a TypeScript project and ships it the conventions it follows, in a .prumo/ folder any AI assistant reads.",
5
5
  "keywords": [
6
6
  "scaffolding",
@@ -10,6 +10,7 @@ POSTGRES_PORT=5432
10
10
  # Signs the access tokens. `prumo new` writes a random one into .env; never reuse it between environments.
11
11
  JWT_SECRET=replace-this-with-at-least-32-characters
12
12
  API_URL=http://localhost:3000
13
+ # Every web app the API answers, comma-separated. The first is where MCP consent opens.
13
14
  WEB_ORIGIN=http://localhost:5173
14
15
  # prumo:social
15
16
  # The mobile app's scheme, as in app.json: where it is sent back after signing in with a provider.
@@ -51,7 +51,8 @@ one comes from.
51
51
  <!-- prumo:mcp -->
52
52
 
53
53
  The MCP server answers at `/api/mcp`. This API is also the OAuth authorization server that issues its tokens: an MCP
54
- client finds it through `/.well-known/oauth-protected-resource`, and the user signs in and consents on the web app.
54
+ client finds it through `/.well-known/oauth-protected-resource`, and the user signs in and consents on the first web
55
+ app in `WEB_ORIGIN`.
55
56
  <!-- prumo:end-mcp -->
56
57
 
57
58
  ## Everyday commands
@@ -14,22 +14,23 @@ import { basename, dirname, join, resolve } from 'node:path'
14
14
  import { createInterface } from 'node:readline/promises'
15
15
  import { parseArgs } from 'node:util'
16
16
 
17
- // Where each app keeps its port, what it falls back to, and how its dev command is told about it.
18
- const APPS = {
17
+ // Where an app of each type keeps its port, what it falls back to, and how its dev command is told about it.
18
+ const TYPES = {
19
19
  api: { key: 'PORT', fallback: 3000, flag: [] },
20
20
  web: { key: 'WEB_PORT', fallback: 5173, flag: ['--port', '--strictPort'] },
21
21
  site: { key: 'SITE_PORT', fallback: 3200, flag: ['--port'] },
22
22
  mobile: { key: 'METRO_PORT', fallback: 8081, flag: ['--port'] },
23
23
  }
24
24
 
25
- // A port that moves takes these with it: the key to write, in which app, from which app's port.
25
+ // A port that moves takes these with it: the key to write, in every app of which type. A list holds one URL per app,
26
+ // so only the moving app's entry changes.
26
27
  const FOLLOWERS = {
27
28
  api: [
28
- { app: 'api', key: 'API_URL' },
29
- { app: 'web', key: 'VITE_API_URL' },
30
- { app: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
29
+ { type: 'api', key: 'API_URL' },
30
+ { type: 'web', key: 'VITE_API_URL' },
31
+ { type: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
31
32
  ],
32
- web: [{ app: 'api', key: 'WEB_ORIGIN' }],
33
+ web: [{ type: 'api', key: 'WEB_ORIGIN', list: true }],
33
34
  site: [],
34
35
  mobile: [],
35
36
  }
@@ -117,36 +118,43 @@ function projectRoot() {
117
118
  const root = projectRoot()
118
119
  const workspace = existsSync(join(root, 'apps'))
119
120
 
120
- function directoryOf(type) {
121
- return workspace ? join(root, 'apps', type) : root
121
+ function readConfig() {
122
+ const path = join(root, '.prumo', 'config.json')
123
+
124
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {}
122
125
  }
123
126
 
124
- function typeOf(directory) {
125
- const name = basename(directory)
127
+ // Every app of the project, as { name, type, directory }. A workspace lists its apps in .prumo/config.json; one written
128
+ // before apps had names holds one per type, named after it. An alone project is its one app, named after its type.
129
+ function projectApps() {
130
+ const config = readConfig()
131
+
132
+ if (!workspace) {
133
+ // A project is named by its author, so only .prumo/config.json says what it is.
134
+ const type = TYPES[basename(root)] !== undefined ? basename(root) : config.types?.[0]
126
135
 
127
- if (APPS[name] !== undefined) {
128
- return name
136
+ return type === undefined ? [] : [{ name: type, type, directory: root }]
129
137
  }
130
138
 
131
- // An alone project is named by its author, so only .prumo/config.json says what it is.
132
- const config = join(root, '.prumo', 'config.json')
133
- const types = existsSync(config) ? JSON.parse(readFileSync(config, 'utf8')).types : []
139
+ const listed = config.apps ?? (config.types ?? []).map((type) => ({ name: type, type }))
134
140
 
135
- return types[0]
141
+ return listed
142
+ .map((app) => ({ ...app, directory: join(root, 'apps', app.name) }))
143
+ .filter(
144
+ (app) => TYPES[app.type] !== undefined && existsSync(join(app.directory, 'package.json')),
145
+ )
136
146
  }
137
147
 
138
- function typesToCheck() {
139
- if (!values.all) {
140
- return [typeOf(here)]
141
- }
148
+ const apps = projectApps()
142
149
 
143
- return Object.keys(APPS).filter((type) => existsSync(join(directoryOf(type), 'package.json')))
150
+ function appsToCheck() {
151
+ return values.all ? apps : apps.filter((app) => app.directory === here)
144
152
  }
145
153
 
146
- function portOf(type) {
147
- const app = APPS[type]
154
+ function portOf(app) {
155
+ const { key, fallback } = TYPES[app.type]
148
156
 
149
- return Number(envValue(readEnv(directoryOf(type)), app.key) ?? app.fallback)
157
+ return Number(envValue(readEnv(app.directory), key) ?? fallback)
150
158
  }
151
159
 
152
160
  function listening(host, port) {
@@ -269,22 +277,31 @@ async function nextFree(from) {
269
277
  throw new Failure('port_busy', `No free port between ${from + 1} and ${from + PORTS_TRIED - 1}.`)
270
278
  }
271
279
 
272
- function movePort(type, port) {
273
- const app = APPS[type]
280
+ function movePort(app, from, port) {
281
+ const before = `http://localhost:${from}`
282
+ const after = `http://localhost:${port}`
274
283
 
275
- writeEnvValue(directoryOf(type), app.key, port)
284
+ writeEnvValue(app.directory, TYPES[app.type].key, port)
276
285
 
277
- for (const follower of FOLLOWERS[type]) {
278
- const directory = directoryOf(follower.app)
286
+ for (const follower of FOLLOWERS[app.type]) {
287
+ for (const other of apps.filter((each) => each.type === follower.type)) {
288
+ const current = envValue(readEnv(other.directory), follower.key)
289
+ const entries = (current ?? '').split(',').map((entry) => entry.trim())
290
+ const value =
291
+ follower.list && current !== undefined
292
+ ? (entries.includes(before)
293
+ ? entries.map((entry) => (entry === before ? after : entry))
294
+ : [...entries, after]
295
+ ).join(',')
296
+ : after
279
297
 
280
- if (existsSync(join(directory, 'package.json'))) {
281
- writeEnvValue(directory, follower.key, `http://localhost:${port}`)
298
+ writeEnvValue(other.directory, follower.key, value)
282
299
  }
283
300
  }
284
301
  }
285
302
 
286
- async function settle(type) {
287
- const port = portOf(type)
303
+ async function settle(app) {
304
+ const port = portOf(app)
288
305
 
289
306
  if (!(await taken(port))) {
290
307
  return port
@@ -292,12 +309,12 @@ async function settle(type) {
292
309
 
293
310
  const found = holders(port)
294
311
 
295
- say(`${type} wants port ${port}, held by ${describe(found)}.`)
312
+ say(`${app.name} wants port ${port}, held by ${describe(found)}.`)
296
313
 
297
314
  const moved = async () => {
298
315
  const free = await nextFree(port)
299
- movePort(type, free)
300
- say(`${type} moved to port ${free}, written to .env.`)
316
+ movePort(app, port, free)
317
+ say(`${app.name} moved to port ${free}, written to .env.`)
301
318
  return free
302
319
  }
303
320
 
@@ -311,7 +328,7 @@ async function settle(type) {
311
328
  if (!interactive) {
312
329
  throw new Failure(
313
330
  'port_busy',
314
- `Port ${port} is in use by ${describe(found)}. Pass --kill to stop it, or --change to move ${type} to the next free port.`,
331
+ `Port ${port} is in use by ${describe(found)}. Pass --kill to stop it, or --change to move ${app.name} to the next free port.`,
315
332
  )
316
333
  }
317
334
 
@@ -669,10 +686,8 @@ async function main() {
669
686
  const starting = command === undefined ? undefined : startJob()
670
687
 
671
688
  try {
672
- for (const type of typesToCheck()) {
673
- if (type !== undefined) {
674
- ports[type] = await settle(type)
675
- }
689
+ for (const app of appsToCheck()) {
690
+ ports[app.name] = await settle(app)
676
691
  }
677
692
  } catch (error) {
678
693
  ;(await starting)?.close()
@@ -685,10 +700,10 @@ async function main() {
685
700
 
686
701
  // Only an app tells its command which port to use. A workspace root wraps `pnpm`, which would pass a stray
687
702
  // `--port` on to every app it runs.
688
- const [type] = values.all ? [] : Object.keys(ports)
689
- const flag = APPS[type ?? '']?.flag ?? []
703
+ const [app] = values.all ? [] : appsToCheck()
704
+ const flag = app === undefined ? [] : TYPES[app.type].flag
690
705
  const withPort = flag.flatMap((entry) =>
691
- entry === '--port' ? ['--port', String(ports[type])] : [entry],
706
+ entry === '--port' ? ['--port', String(ports[app.name])] : [entry],
692
707
  )
693
708
  // Before the command starts, so that it and everything it starts are members from their first instant.
694
709
  const job = await starting
@@ -1,6 +1,9 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { z } from 'zod'
3
3
 
4
+ // Written as a browser sends it in an Origin header: scheme, host and port, with no path or trailing slash.
5
+ const origin = z.url().transform((url) => new URL(url).origin)
6
+
4
7
  const envSchema = z.object({
5
8
  NODE_ENV: z.enum(['development', 'test', 'production']),
6
9
  PORT: z.coerce.number().int().min(1).max(65535),
@@ -8,7 +11,11 @@ const envSchema = z.object({
8
11
  JWT_SECRET: z.string().min(32),
9
12
  // Where this API is reached from outside: the issuer of its tokens, and what decides whether cookies are Secure.
10
13
  API_URL: z.url(),
11
- WEB_ORIGIN: z.string().min(1),
14
+ // Every web app this API answers, comma-separated. The first is the one MCP consent opens on.
15
+ WEB_ORIGIN: z
16
+ .string()
17
+ .transform((value) => value.split(',').map((origin) => origin.trim()))
18
+ .pipe(z.tuple([origin], origin)),
12
19
  // prumo:social
13
20
  // Where a mobile app is sent back after signing in with a provider: its own scheme, as in app.json.
14
21
  MOBILE_APP_SCHEME: z
@@ -129,8 +129,8 @@ export async function oauthController(fastify: FastifyInstance): Promise<void> {
129
129
  scopesSupported,
130
130
  )
131
131
 
132
- // Signing in, if needed, and the consent itself happen on the web app.
133
- return reply.redirect(`${env.WEB_ORIGIN}/consent?request=${authorization.id}`)
132
+ // Signing in, if needed, and the consent itself happen on the first web app.
133
+ return reply.redirect(`${env.WEB_ORIGIN[0]}/consent?request=${authorization.id}`)
134
134
  } catch (error) {
135
135
  if (error instanceof AuthorizationRequestError) {
136
136
  return reply.redirect(
@@ -51,10 +51,14 @@ export async function socialController(fastify: FastifyInstance): Promise<void>
51
51
  path: '/api/auth/social',
52
52
  } as const
53
53
 
54
- const back = (client: SocialClient, query: Record<string, string>) =>
54
+ // A web app is sent back to itself, so only an origin this API trusts is ever a destination.
55
+ const trusted = (origin: string | undefined) =>
56
+ origin !== undefined && env.WEB_ORIGIN.includes(origin) ? origin : env.WEB_ORIGIN[0]
57
+
58
+ const back = (client: SocialClient, origin: string, query: Record<string, string>) =>
55
59
  client === 'mobile'
56
60
  ? `${env.MOBILE_APP_SCHEME}://social?${new URLSearchParams(query)}`
57
- : `${env.WEB_ORIGIN}/sign-in?${new URLSearchParams(query)}`
61
+ : `${origin}/sign-in?${new URLSearchParams(query)}`
58
62
 
59
63
  // Apple's answer is a form.
60
64
  app.addContentTypeParser(
@@ -73,11 +77,19 @@ export async function socialController(fastify: FastifyInstance): Promise<void>
73
77
  querystring: z.object({
74
78
  client: z.enum(['web', 'mobile']).default('web'),
75
79
  returnTo: z.string().max(512).optional(),
80
+ // The web app that started, when there are several; one the API does not trust is refused.
81
+ origin: z.string().max(512).optional(),
76
82
  }),
77
83
  },
78
84
  },
79
85
  async (request, reply) => {
80
- const { client, returnTo } = request.query
86
+ const { client, returnTo, origin: requested } = request.query
87
+
88
+ if (requested !== undefined && !env.WEB_ORIGIN.includes(requested)) {
89
+ throw new HttpError(422, 'origin is not one of the web apps this API trusts')
90
+ }
91
+
92
+ const origin = client === 'web' ? trusted(requested) : ''
81
93
 
82
94
  if (client === 'mobile' && env.MOBILE_APP_SCHEME === undefined) {
83
95
  throw new HttpError(
@@ -92,8 +104,9 @@ export async function socialController(fastify: FastifyInstance): Promise<void>
92
104
  returnTo: safePath(returnTo),
93
105
  })
94
106
 
95
- // The client rides beside the token, so an error found before the flow is read still goes to the right place.
96
- reply.setCookie(BROWSER_COOKIE, `${client}~${started.browserToken}`, {
107
+ // The client and its origin ride beside the token, so an error found before the flow is read still goes to the
108
+ // right place.
109
+ reply.setCookie(BROWSER_COOKIE, `${client}~${origin}~${started.browserToken}`, {
97
110
  ...cookie,
98
111
  maxAge: BROWSER_COOKIE_MAX_AGE_S,
99
112
  })
@@ -132,16 +145,17 @@ export async function socialController(fastify: FastifyInstance): Promise<void>
132
145
  },
133
146
  },
134
147
  async (request, reply) => {
135
- const [client, browserToken] = (request.cookies[BROWSER_COOKIE] ?? 'web~').split('~') as [
136
- SocialClient,
137
- string,
138
- ]
148
+ const [client, written, browserToken] = (request.cookies[BROWSER_COOKIE] ?? 'web~~').split(
149
+ '~',
150
+ ) as [SocialClient, string | undefined, string | undefined]
151
+ // Read back through the list, so a cookie edited by hand cannot name a destination of its own.
152
+ const origin = trusted(written)
139
153
  const { state, code, error } = request.query
140
154
 
141
155
  reply.clearCookie(BROWSER_COOKIE, cookie)
142
156
 
143
157
  if (error !== undefined || state === undefined || code === undefined) {
144
- return reply.redirect(back(client, { error: 'social_sign_in_failed' }))
158
+ return reply.redirect(back(client, origin, { error: 'social_sign_in_failed' }))
145
159
  }
146
160
 
147
161
  try {
@@ -154,15 +168,17 @@ export async function socialController(fastify: FastifyInstance): Promise<void>
154
168
  })
155
169
 
156
170
  if (outcome.client === 'mobile') {
157
- return reply.redirect(back('mobile', { code: outcome.code }))
171
+ return reply.redirect(back('mobile', origin, { code: outcome.code }))
158
172
  }
159
173
 
160
174
  setCookies(reply, outcome.tokens)
161
175
 
162
- return reply.redirect(`${env.WEB_ORIGIN}${outcome.returnTo}`)
176
+ return reply.redirect(`${origin}${outcome.returnTo}`)
163
177
  } catch (caught) {
164
178
  if (caught instanceof DomainError) {
165
- return reply.redirect(back(client, { error: caught.code ?? 'social_sign_in_failed' }))
179
+ return reply.redirect(
180
+ back(client, origin, { error: caught.code ?? 'social_sign_in_failed' }),
181
+ )
166
182
  }
167
183
 
168
184
  throw caught
@@ -4,9 +4,9 @@ import { HttpError } from '@/infra/http/errors/http-error'
4
4
  const SAFE = new Set(['GET', 'HEAD', 'OPTIONS'])
5
5
 
6
6
  // A cookie is sent by the browser whoever wrote the page, so a request that changes something and leans on one must
7
- // come from the web app. Without a cookie there is nothing for another site to borrow: a native app and an MCP
8
- // client send a bearer, and pass.
9
- export function registerOriginCheck(app: FastifyInstance, webOrigin: string): void {
7
+ // come from one of the web apps. Without a cookie there is nothing for another site to borrow: a native app and an
8
+ // MCP client send a bearer, and pass.
9
+ export function registerOriginCheck(app: FastifyInstance, webOrigins: string[]): void {
10
10
  app.addHook('onRequest', async (request) => {
11
11
  const leansOnCookie =
12
12
  request.headers.cookie !== undefined && request.headers.authorization === undefined
@@ -15,7 +15,7 @@ export function registerOriginCheck(app: FastifyInstance, webOrigin: string): vo
15
15
  return
16
16
  }
17
17
 
18
- if (request.headers.origin !== webOrigin) {
18
+ if (request.headers.origin === undefined || !webOrigins.includes(request.headers.origin)) {
19
19
  throw new HttpError(403, 'Cross-origin request refused')
20
20
  }
21
21
  })
@@ -1,4 +1,4 @@
1
- import { importPKCS8, SignJWT } from 'jose'
1
+ import { importPKCS8, SignJWT } from 'jose' // prumo:apple
2
2
  import * as oidc from 'openid-client'
3
3
  import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
4
4
  import type {
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { validate } from '@/infra/config/env'
3
+
4
+ const RAW = {
5
+ NODE_ENV: 'test',
6
+ PORT: '3000',
7
+ DATABASE_URL: 'postgres://app:app@localhost:5432/app',
8
+ JWT_SECRET: 'test-secret-that-is-at-least-32-characters',
9
+ API_URL: 'http://localhost:3000',
10
+ LOG_LEVEL: 'error',
11
+ }
12
+
13
+ describe('WEB_ORIGIN', () => {
14
+ it('holds every web app, comma-separated, written as a browser sends its origin', () => {
15
+ const env = validate({ ...RAW, WEB_ORIGIN: 'http://localhost:5173, http://localhost:5174/' })
16
+
17
+ expect(env.WEB_ORIGIN).toEqual(['http://localhost:5173', 'http://localhost:5174'])
18
+ })
19
+
20
+ it('refuses an empty list and an entry that is not a URL', () => {
21
+ expect(() => validate({ ...RAW, WEB_ORIGIN: '' })).toThrow('WEB_ORIGIN')
22
+ expect(() => validate({ ...RAW, WEB_ORIGIN: 'http://localhost:5173,backoffice' })).toThrow(
23
+ 'WEB_ORIGIN',
24
+ )
25
+ })
26
+ })
@@ -1,3 +1,4 @@
1
+ import { testEnv } from '@test/support/setup'
1
2
  import { ORIGIN, PASSWORD, testApp } from '@test/support/test-app'
2
3
  import type { FastifyInstance, LightMyRequestResponse } from 'fastify'
3
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -83,6 +84,29 @@ describe('auth controller, on the web', () => {
83
84
  })
84
85
  })
85
86
 
87
+ describe('auth controller, with several web apps', () => {
88
+ it('lets each of them lean on its cookie, and tells the browser so', async () => {
89
+ const backoffice = 'http://localhost:5174'
90
+ const several = await testApp({ env: { ...testEnv(), WEB_ORIGIN: [ORIGIN, backoffice] } })
91
+ const cookie = `access_token=${cookiesOf((await several.signUp()).response).access_token}`
92
+ const signedOut = await several.app.inject({
93
+ method: 'POST',
94
+ url: '/api/auth/sign-out',
95
+ headers: { cookie, origin: backoffice },
96
+ })
97
+ const preflight = await several.app.inject({
98
+ method: 'OPTIONS',
99
+ url: '/api/auth/sign-out',
100
+ headers: { origin: backoffice, 'access-control-request-method': 'POST' },
101
+ })
102
+
103
+ expect(signedOut.statusCode).toBe(204)
104
+ expect(preflight.headers['access-control-allow-origin']).toBe(backoffice)
105
+
106
+ await several.app.close()
107
+ })
108
+ })
109
+
86
110
  describe('auth controller, for a native client', () => {
87
111
  it('hands the tokens over in the body, and accepts them as a bearer', async () => {
88
112
  const { response } = await signUp({ bearer: true })
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6
6
  import { IDENTITY_PROVIDERS } from '@/domain/auth/ports/identity-providers.port'
7
7
 
8
8
  const WEB = 'http://localhost:5173'
9
+ const BACKOFFICE = 'http://localhost:5174'
9
10
 
10
11
  // Whichever provider the project kept: the flow is the same for each.
11
12
  const PROVIDER = [
@@ -26,7 +27,7 @@ function stateOf(response: LightMyRequestResponse): string {
26
27
 
27
28
  beforeEach(async () => {
28
29
  ;({ app } = await testApp({
29
- env: { ...testEnv(), MOBILE_APP_SCHEME: 'acme' },
30
+ env: { ...testEnv(), WEB_ORIGIN: [WEB, BACKOFFICE], MOBILE_APP_SCHEME: 'acme' },
30
31
  configure: (container) =>
31
32
  container.register(IDENTITY_PROVIDERS, { useValue: new FakeIdentityProviders() }),
32
33
  }))
@@ -69,6 +70,27 @@ describe('social sign-in on the web', () => {
69
70
  expect(back.headers.location).toBe(`${WEB}/`)
70
71
  })
71
72
 
73
+ it('sends each web app back to itself, and refuses an origin it does not trust', async () => {
74
+ const started = await app.inject({
75
+ url: `/api/auth/social/${PROVIDER}?returnTo=/orders&origin=${encodeURIComponent(BACKOFFICE)}`,
76
+ })
77
+ const back = await app.inject({
78
+ url: `/api/auth/social/${PROVIDER}/callback?code=c&state=${stateOf(started)}`,
79
+ headers: { cookie: browserCookie(started) },
80
+ })
81
+ const failed = await app.inject({
82
+ url: `/api/auth/social/${PROVIDER}/callback?error=access_denied`,
83
+ headers: { cookie: browserCookie(started) },
84
+ })
85
+ const foreign = await app.inject({
86
+ url: `/api/auth/social/${PROVIDER}?origin=${encodeURIComponent('https://evil.example')}`,
87
+ })
88
+
89
+ expect(back.headers.location).toBe(`${BACKOFFICE}/orders`)
90
+ expect(failed.headers.location).toBe(`${BACKOFFICE}/sign-in?error=social_sign_in_failed`)
91
+ expect(foreign.statusCode).toBe(422)
92
+ })
93
+
72
94
  it('turns a form post, as Apple sends, into a redirect the browser’s cookie comes with', async () => {
73
95
  const posted = await app.inject({
74
96
  method: 'POST',
@@ -25,7 +25,7 @@ export function testEnv(): Env {
25
25
  DATABASE_URL: databaseUrl,
26
26
  JWT_SECRET: 'test-secret-that-is-at-least-32-characters',
27
27
  API_URL: 'http://localhost:3000',
28
- WEB_ORIGIN: 'http://localhost:5173',
28
+ WEB_ORIGIN: ['http://localhost:5173'],
29
29
  LOG_LEVEL: 'error',
30
30
  }
31
31
  }