orionplace-nuxt 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Orionplace contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # orionplace-nuxt
2
+
3
+ Set up a Nuxt 4 storefront for your personal Orionplace marketplace API.
4
+ The package includes a Nuxt module, a setup CLI, and commented server source
5
+ that is copied into your project alongside an integration README.
6
+
7
+ Requires Node.js 22.12+ and a Node server deployment. Static-only and edge
8
+ hosting are not supported by this filesystem-based integration.
9
+
10
+ ## New project
11
+
12
+ Your personal backend domain is already provided in your account at
13
+ [orionmarket.place](https://orionmarket.place). You can change it there.
14
+ Use that domain below and the public domain of your own storefront.
15
+
16
+ ```bash
17
+ mkdir my-marketplace
18
+ cd my-marketplace
19
+ npm install orionplace-nuxt
20
+ npx orionplace-nuxt set backend your-project.orionplace.app
21
+ npx orionplace-nuxt add frontend shop.example.com
22
+ npm install
23
+ npm run dev
24
+ ```
25
+
26
+ The first setup command creates the Nuxt configuration and app if needed.
27
+ The final `npm install` installs the dependencies added to `package.json`.
28
+ The backend command accepts either a hostname or its full HTTPS `/api` URL.
29
+ The frontend command accepts a public hostname or its root HTTPS URL.
30
+
31
+ You can also scaffold before installing:
32
+
33
+ ```bash
34
+ npx orionplace-nuxt init my-marketplace
35
+ cd my-marketplace
36
+ npm install
37
+ npx orionplace-nuxt set backend your-project.orionplace.app
38
+ npx orionplace-nuxt add frontend shop.example.com
39
+ npm run dev
40
+ ```
41
+
42
+ ## Existing Nuxt 4 project
43
+
44
+ ```bash
45
+ npm install orionplace-nuxt
46
+ npx orionplace-nuxt init --dry-run
47
+ npx orionplace-nuxt set backend your-project.orionplace.app
48
+ npx orionplace-nuxt add frontend shop.example.com
49
+ ```
50
+
51
+ The CLI preserves unrelated Nuxt settings, scripts, and environment variables.
52
+ It supports a standard root `nuxt.config.ts`, `.js`, or `.mjs` with a literal
53
+ configuration object and module array. Resolve existing `/api` proxy or
54
+ catch-all conflicts before setup. Custom source/server directories, Nuxt layers,
55
+ and dynamic configuration expressions require manual integration.
56
+
57
+ No install or postinstall hook changes your application. Only explicitly running
58
+ the CLI writes files. Use `--cwd <directory>` to select a project and `--dry-run`
59
+ to inspect planned changes without writing them.
60
+
61
+ ## Commands
62
+
63
+ ```bash
64
+ npx orionplace-nuxt init [directory]
65
+ npx orionplace-nuxt set backend your-project.orionplace.app
66
+ npx orionplace-nuxt add frontend shop.example.com
67
+ npx orionplace-nuxt add frontend www.shop.example.com
68
+ npx orionplace-nuxt remove frontend www.shop.example.com
69
+ npx orionplace-nuxt set frontend shop.example.com
70
+ npx orionplace-nuxt --help
71
+ ```
72
+
73
+ Either domain command can run first. Both settings are needed for a usable mapping.
74
+ After setup, these npm script aliases are also available:
75
+
76
+ ```bash
77
+ npm run orionplace:backend -- your-project.orionplace.app
78
+ npm run orionplace:add-frontend -- shop.example.com
79
+ npm run orionplace:remove-frontend -- www.shop.example.com
80
+ ```
81
+
82
+ `npm set` belongs to npm's own configuration system; use the commands above.
83
+ These commands configure local integration files. They do not provision a backend,
84
+ change your account, create DNS records, configure TLS, or deploy a VPS.
85
+
86
+ ## Generated files
87
+
88
+ | Path | Purpose |
89
+ | --- | --- |
90
+ | `nuxt.config.ts` | Registers the module; an existing supported config keeps its filename. |
91
+ | `orionplace/runtime/marketplace.mjs` | Commented domain lookup and HTTP transport. |
92
+ | `orionplace/runtime/api.mjs` | Nitro handler registered for `/api/**`. |
93
+ | `orionplace/runtime/domain.mjs` | Domain resolution middleware. |
94
+ | `orionplace/README.md` | Your settings, API examples, file explanations, verification and VPS deployment. |
95
+ | `orionplace/settings.json` | Domain values selected by the CLI. |
96
+ | `orionplace/.generated.json` | File hashes used to preserve your edits on subsequent setup. |
97
+ | `sitemaps/shop.example.com.cfg` | One line: `https://your-project.orionplace.app/api`. |
98
+ | `.env` | `MIMIC_FRONT_DOMAIN="shop.example.com"` for localhost. |
99
+
100
+ The module executes the generated local runtime files. You can read and customize
101
+ them. Later setup runs preserve edited runtime files and the project README,
102
+ reporting which files were preserved. Unchanged generated files can be refreshed
103
+ by running `init` after upgrading the package. A manually edited domain file is
104
+ never silently overwritten. Keep the hash manifest in version control.
105
+
106
+ ## Multiple frontend domains
107
+
108
+ Each frontend gets its own `sitemaps/<domain>.cfg`. `add frontend` preserves other
109
+ domains and can be repeated safely. `set backend` updates every CLI-managed mapping
110
+ to the new personal API; it refuses to overwrite a manually edited mapping.
111
+
112
+ The first added frontend becomes `MIMIC_FRONT_DOMAIN` in `.env`. Further additions
113
+ preserve that local development choice. `set frontend <domain>` adds the domain
114
+ if needed and selects it for MIMIC; it does not remove other domains.
115
+
116
+ `remove frontend <domain>` removes that domain from the list and deletes only its
117
+ unchanged generated `.cfg`. Removing the MIMIC domain selects the first remaining
118
+ domain. Removing the last one clears MIMIC. Unknown or edited mappings cause a
119
+ clear error without changing files. Unrelated configurations remain untouched.
120
+
121
+ Restart development after domain changes to refresh Vite's allowed hosts and
122
+ Nuxt's environment. Production reloads mapping files on each API request; reload
123
+ the process environment when changing MIMIC. Configure DNS, TLS, and the hosting
124
+ proxy for each frontend separately.
125
+
126
+ Existing 0.1.0 setup state is migrated automatically, including other domain
127
+ mappings recorded in its generated-file manifest.
128
+
129
+ ## Runtime behavior
130
+
131
+ The hosting proxy must overwrite `X-Front-Domain`, `X-Forwarded-Host`, and `Host`.
132
+ The gateway selects the first valid public domain in that order, with the request
133
+ URL hostname as another fallback. On localhost it uses `MIMIC_FRONT_DOMAIN`.
134
+ An unknown public domain returns `503`; it never uses another domain's mapping.
135
+
136
+ The corresponding `.cfg` is read on every request. Backend URLs must use HTTPS,
137
+ end in `/api`, and belong to `*.orionplace.app`. API methods, query strings, bodies,
138
+ authorization and cookies are forwarded. Backend statuses are retained and cookie
139
+ Domain attributes are removed to bind cookies to the storefront. Gateway responses
140
+ use `Cache-Control: no-store`. Network failures return `502`.
141
+
142
+ Browser requests use `/api/...`. For SSR, use Nuxt's `useRequestFetch()` to pass
143
+ the current request's domain and authentication to the same gateway. See the
144
+ project README for examples and cookie handling.
145
+
146
+ The module sets private `runtimeConfig.mimicFrontDomain`, adds domain `Vary`
147
+ headers, extends Vite's allowed hosts from `.cfg` filenames, and defaults Nitro
148
+ to `node-server`. It does not set a fixed public site URL or generate canonical
149
+ links for your pages; use the resolved request domain for your own SEO metadata.
150
+
151
+ Deploy `sitemaps` with the production server, or set an absolute `SITEMAPS_DIR`.
152
+ The production process reads `MIMIC_FRONT_DOMAIN` without rebuilding. Run the
153
+ server from the project root or explicitly point it at the configuration directory.
154
+
155
+ ## Documentation
156
+
157
+ - [Create a Nuxt frontend](https://orionmarket.place/sandbox/new-project/nuxt)
158
+ - [Connect your personal backend](https://orionmarket.place/sandbox/backend-domain/nuxt)
159
+ - [Marketplace API documentation](https://orionmarket.place/sandbox/)
160
+
161
+ ## Development
162
+
163
+ ```bash
164
+ npm install
165
+ npm test
166
+ npm pack --dry-run
167
+ ```
168
+
169
+ The published tarball includes only the CLI, module, runtime source, README,
170
+ license, and package metadata.
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ import { setupProject } from '../src/setup.mjs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
6
+
7
+ const help = `Orionplace Nuxt setup
8
+
9
+ npx orionplace-nuxt init [directory]
10
+ npx orionplace-nuxt set backend <personal-backend.orionplace.app>
11
+ npx orionplace-nuxt add frontend <shop.example.com>
12
+ npx orionplace-nuxt remove frontend <shop.example.com>
13
+ npx orionplace-nuxt set frontend <shop.example.com>
14
+
15
+ Options: --cwd <directory> --dry-run --help --version
16
+
17
+ Set backend or add frontend also initializes the integration; either can come first.
18
+ Add frontend keeps other domains and the current MIMIC domain. Set frontend selects MIMIC.
19
+ Set backend updates every managed frontend mapping. Remove frontend deletes only that mapping.
20
+ After creating a new project, run npm install and npm run dev in that directory.
21
+ Documentation and editable server source are generated in orionplace/.`;
22
+ try {
23
+ const argv = process.argv.slice(2);
24
+ if (!argv.length || argv.includes('--help') || argv.includes('-h')) {
25
+ console.log(help);
26
+ } else if (argv.includes('--version')) {
27
+ console.log(JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version);
28
+ } else {
29
+ const positional = [];
30
+ let cwd = process.cwd();
31
+ let dryRun = false;
32
+ for (let index = 0; index < argv.length; index++) {
33
+ if (argv[index] === '--dry-run') dryRun = true;
34
+ else if (argv[index] === '--cwd') {
35
+ if (!argv[index + 1]) throw new Error('--cwd needs a directory');
36
+ cwd = resolve(argv[++index]);
37
+ } else if (argv[index].startsWith('--')) throw new Error(`Unknown option: ${argv[index]}`);
38
+ else positional.push(argv[index]);
39
+ }
40
+ const [command, target] = positional;
41
+ let result;
42
+ if (command === 'init' && positional.length <= 2) {
43
+ result = await setupProject({ cwd: target ? resolve(cwd, target) : cwd, dryRun });
44
+ } else if (((command === 'set' && ['backend', 'frontend'].includes(target)) ||
45
+ (['add', 'remove'].includes(command) && target === 'frontend')) && positional.length <= 3) {
46
+ let value = positional[2];
47
+ if (!value && process.stdin.isTTY) {
48
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
49
+ try { value = await prompt.question(`${target === 'backend' ? 'Personal backend API' : 'Public frontend domain'}: `); }
50
+ finally { prompt.close(); }
51
+ }
52
+ if (!value) throw new Error(`Provide the ${target} domain. See --help.`);
53
+ result = await setupProject({ cwd, action: command, field: target, value, dryRun });
54
+ } else throw new Error('Unknown command. See --help.');
55
+ console.log(`${dryRun ? 'Planned' : 'Updated'} ${result.root}`);
56
+ for (const path of result.changed) console.log(` ${result.removed.includes(path) ? 'Remove: ' : ''}${path}`);
57
+ for (const path of result.preserved) console.log(` Preserved your edits: ${path}`);
58
+ console.log(result.settings.backend && result.settings.frontend
59
+ ? `Ready: ${result.settings.frontends.join(', ')} -> ${result.settings.backend}\nLocal MIMIC: ${result.settings.frontend}`
60
+ : `Next: ${result.settings.backend ? 'add frontend' : 'set backend'} to complete the domain mapping.`);
61
+ if (result.changed.some(path => path.endsWith('.cfg') || path === '.env')) console.log('Restart a running development server to refresh allowed hosts and MIMIC. Reload your production environment when changing MIMIC.');
62
+ if (result.isNewNuxt) console.log('Run npm install in the project, then npm run dev.');
63
+ console.log('Project documentation: orionplace/README.md');
64
+ }
65
+ } catch (error) {
66
+ console.error(`Orionplace: ${error.message}`);
67
+ process.exitCode = 1;
68
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "orionplace-nuxt",
3
+ "version": "0.1.1",
4
+ "description": "Nuxt integration and setup CLI for your Orionplace personal marketplace backend.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Orionplace",
8
+ "homepage": "https://orionmarket.place/sandbox/backend-domain/nuxt",
9
+ "keywords": [
10
+ "nuxt",
11
+ "nuxt-module",
12
+ "orionplace",
13
+ "marketplace"
14
+ ],
15
+ "engines": {
16
+ "node": ">=22.12.0"
17
+ },
18
+ "bin": {
19
+ "orionplace-nuxt": "./bin/orionplace-nuxt.mjs"
20
+ },
21
+ "main": "./src/module.mjs",
22
+ "types": "./src/module.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./src/module.d.ts",
26
+ "import": "./src/module.mjs"
27
+ },
28
+ "./runtime": "./runtime/marketplace.mjs"
29
+ },
30
+ "files": [
31
+ "bin",
32
+ "src",
33
+ "runtime",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "test": "node --test test/*.test.mjs"
39
+ },
40
+ "peerDependencies": {
41
+ "nuxt": "^4.0.0"
42
+ },
43
+ "dependencies": {
44
+ "@nuxt/kit": "^4.5.0",
45
+ "h3": "^1.15.4",
46
+ "magicast": "^0.5.0"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "registry": "https://registry.npmjs.org/"
51
+ }
52
+ }
@@ -0,0 +1,11 @@
1
+ // All browser and SSR /api requests use this gateway.
2
+ import { defineEventHandler, toWebRequest, sendWebResponse } from 'h3';
3
+ import { useRuntimeConfig } from '#imports';
4
+ import { forwardMarketplace } from './marketplace.mjs';
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const response = await forwardMarketplace(toWebRequest(event), {
8
+ mimicFrontDomain: process.env.MIMIC_FRONT_DOMAIN ?? useRuntimeConfig(event).mimicFrontDomain,
9
+ });
10
+ return sendWebResponse(event, response);
11
+ });
@@ -0,0 +1,9 @@
1
+ // Other server code can read event.context.marketplaceDomain for canonical URLs.
2
+ import { defineEventHandler, toWebRequest } from 'h3';
3
+ import { useRuntimeConfig } from '#imports';
4
+ import { resolveFrontDomain } from './marketplace.mjs';
5
+
6
+ export default defineEventHandler((event) => {
7
+ event.context.marketplaceDomain = resolveFrontDomain(toWebRequest(event),
8
+ process.env.MIMIC_FRONT_DOMAIN ?? useRuntimeConfig(event).mimicFrontDomain);
9
+ });
@@ -0,0 +1,211 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { isIP } from 'node:net';
4
+
5
+ const environment = process.env;
6
+
7
+ const hostnamePattern =
8
+ /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
9
+
10
+ /** @param {unknown} value */
11
+ export function normalizeFrontDomain(value) {
12
+ if (typeof value !== 'string') return null;
13
+ const domain = value.split(',')[0].trim().toLowerCase().replace(/\.$/, '');
14
+ return hostnamePattern.test(domain) && !isIP(domain) ? domain : null;
15
+ }
16
+
17
+ /** @param {Request} request @param {string} mimic */
18
+ export function resolveFrontDomain(
19
+ request,
20
+ mimic = environment.MIMIC_FRONT_DOMAIN || '',
21
+ ) {
22
+ // The hosting proxy must overwrite these headers. Public hosts win over MIMIC.
23
+ for (const candidate of [
24
+ request.headers.get('x-front-domain'),
25
+ request.headers.get('x-forwarded-host'),
26
+ request.headers.get('host'),
27
+ new URL(request.url).hostname,
28
+ ]) {
29
+ const domain = normalizeFrontDomain(candidate);
30
+ if (domain) return domain;
31
+ }
32
+ return normalizeFrontDomain(mimic);
33
+ }
34
+
35
+ /** @param {Request} request @param {{mimicFrontDomain?: string}} configuration */
36
+ export async function readMarketplace(request, configuration = {}) {
37
+ const domain = resolveFrontDomain(request, configuration.mimicFrontDomain);
38
+ if (!domain) throw new Error('Invalid storefront domain');
39
+
40
+ const directory = environment.SITEMAPS_DIR || resolve('sitemaps');
41
+ const source = await readFile(resolve(directory, `${domain}.cfg`), 'utf8');
42
+ const lines = source
43
+ .split(/\r?\n/)
44
+ .map((line) => line.trim())
45
+ .filter((line) => line && !line.startsWith('#'));
46
+ if (lines.length !== 1) throw new Error('Expected one API URL');
47
+ const value = lines[0];
48
+ const api = new URL(value.includes('://') ? value : `https://${value}`);
49
+ api.pathname = api.pathname.replace(/\/+$/, '');
50
+ if (
51
+ api.protocol !== 'https:' ||
52
+ !hostnamePattern.test(api.hostname) ||
53
+ !api.hostname.endsWith('.orionplace.app') ||
54
+ isIP(api.hostname) ||
55
+ api.port ||
56
+ api.username ||
57
+ api.password ||
58
+ api.search ||
59
+ api.hash ||
60
+ api.pathname !== '/api'
61
+ ) {
62
+ throw new Error('Expected https://<project>.orionplace.app/api');
63
+ }
64
+ return { domain, api };
65
+ }
66
+
67
+ /** @param {number} status @param {string} message */
68
+ function failure(status, message) {
69
+ return Response.json(
70
+ { error: message },
71
+ {
72
+ status,
73
+ headers: {
74
+ 'Cache-Control': 'no-store',
75
+ 'Vary': 'Host, X-Forwarded-Host, X-Front-Domain',
76
+ },
77
+ },
78
+ );
79
+ }
80
+
81
+ /** @param {Request} request @param {{mimicFrontDomain?: string}} configuration */
82
+ export async function forwardMarketplace(request, configuration = {}) {
83
+ let tenant;
84
+ try {
85
+ tenant = await readMarketplace(request, configuration);
86
+ } catch {
87
+ return failure(
88
+ 503,
89
+ 'Storefront domain configuration is missing or invalid',
90
+ );
91
+ }
92
+
93
+ const incoming = new URL(request.url);
94
+ if (!incoming.pathname.startsWith('/api/'))
95
+ return failure(404, 'Unknown API path');
96
+ // Prevent encoded separators or traversal from escaping the /api namespace.
97
+ let path;
98
+ try {
99
+ path = decodeURIComponent(incoming.pathname);
100
+ } catch {
101
+ return failure(400, 'Invalid API path');
102
+ }
103
+ if (
104
+ path.includes('\\') ||
105
+ /%(?:2e|2f|5c)/i.test(path) ||
106
+ path.split('/').some((part) => part === '.' || part === '..')
107
+ ) {
108
+ return failure(400, 'Invalid API path');
109
+ }
110
+ const target = new URL(
111
+ incoming.pathname + incoming.search,
112
+ tenant.api.origin,
113
+ );
114
+ if (!target.pathname.startsWith('/api/'))
115
+ return failure(400, 'Invalid API path');
116
+
117
+ const origin = request.headers.get('origin');
118
+ const publicHost = request.headers.get('host') || incoming.host;
119
+ const originHosts = [publicHost, tenant.domain];
120
+ if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method)) {
121
+ try {
122
+ if (
123
+ request.headers.get('sec-fetch-site') === 'cross-site' ||
124
+ (origin && !originHosts.includes(new URL(origin).host))
125
+ ) {
126
+ return failure(403, 'Cross-site API request rejected');
127
+ }
128
+ } catch {
129
+ return failure(403, 'Invalid Origin');
130
+ }
131
+ }
132
+
133
+ // An allowlist excludes hop-by-hop and client-supplied routing headers.
134
+ const headers = new Headers();
135
+ for (const name of [
136
+ 'accept',
137
+ 'content-type',
138
+ 'authorization',
139
+ 'cookie',
140
+ 'origin',
141
+ 'referer',
142
+ 'x-csrftoken',
143
+ 'accept-language',
144
+ 'user-agent',
145
+ 'x-tz',
146
+ ]) {
147
+ const value = request.headers.get(name);
148
+ if (value) headers.set(name, value);
149
+ }
150
+ headers.set('X-Front-Domain', tenant.domain);
151
+ headers.set('Accept-Encoding', 'identity');
152
+ const clientIp =
153
+ request.headers.get('x-init-ip') ||
154
+ request.headers.get('x-real-ip') ||
155
+ request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
156
+ if (clientIp) headers.set('X-Init-Ip', clientIp);
157
+ /** @type {RequestInit & { duplex: 'half' }} */
158
+ const options = {
159
+ method: request.method,
160
+ headers,
161
+ redirect: 'manual',
162
+ cache: 'no-store',
163
+ signal: AbortSignal.any([request.signal, AbortSignal.timeout(30_000)]),
164
+ duplex: 'half',
165
+ };
166
+ if (!['GET', 'HEAD'].includes(request.method)) options.body = request.body;
167
+
168
+ let upstream;
169
+ try {
170
+ upstream = await fetch(target, options);
171
+ } catch {
172
+ return failure(502, 'Marketplace backend is unavailable');
173
+ }
174
+
175
+ const responseHeaders = new Headers();
176
+ for (const name of [
177
+ 'content-type',
178
+ 'content-disposition',
179
+ 'www-authenticate',
180
+ 'retry-after',
181
+ 'allow',
182
+ ]) {
183
+ const value = upstream.headers.get(name);
184
+ if (value) responseHeaders.set(name, value);
185
+ }
186
+ // Keep each cookie separate; remove the backend Domain attribute only.
187
+ for (const cookie of upstream.headers.getSetCookie()) {
188
+ responseHeaders.append(
189
+ 'Set-Cookie',
190
+ cookie.replace(/;\s*Domain=[^;]*/gi, ''),
191
+ );
192
+ }
193
+ const location = upstream.headers.get('location');
194
+ if (location) {
195
+ const destination = new URL(location, target);
196
+ responseHeaders.set(
197
+ 'Location',
198
+ destination.origin === tenant.api.origin
199
+ ? destination.pathname + destination.search + destination.hash
200
+ : destination.href,
201
+ );
202
+ }
203
+ responseHeaders.set('Cache-Control', 'no-store');
204
+ responseHeaders.set('Vary', 'Host, X-Forwarded-Host, X-Front-Domain');
205
+ // fetch decodes compressed bodies: do not reuse upstream Content-Length/Encoding.
206
+ return new Response(upstream.body, {
207
+ status: upstream.status,
208
+ statusText: upstream.statusText,
209
+ headers: responseHeaders,
210
+ });
211
+ }
@@ -0,0 +1,3 @@
1
+ import type { NuxtModule } from '@nuxt/schema';
2
+ declare const module: NuxtModule<Record<string, never>>;
3
+ export default module;
package/src/module.mjs ADDED
@@ -0,0 +1,49 @@
1
+ import { defineNuxtModule, addServerHandler } from '@nuxt/kit';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { normalizeFrontDomain } from '../runtime/marketplace.mjs';
5
+
6
+ export default defineNuxtModule({
7
+ meta: { name: 'orionplace-nuxt', configKey: 'orionplace', compatibility: { nuxt: '^4.0.0' } },
8
+ setup(_options, nuxt) {
9
+ const directory = resolve(nuxt.options.rootDir, 'orionplace/runtime');
10
+ // Generated sources live outside server/, so inline them in development too.
11
+ nuxt.options.nitro.externals ||= {};
12
+ nuxt.options.nitro.externals.inline ||= [];
13
+ nuxt.options.nitro.externals.inline.push(directory);
14
+ if (!existsSync(resolve(directory, 'api.mjs'))) {
15
+ throw new Error('Run npx orionplace-nuxt init to generate the Orionplace integration.');
16
+ }
17
+ if (nuxt.options.nitro.preset && nuxt.options.nitro.preset !== 'node-server') {
18
+ throw new Error('Orionplace domain files require the Nitro node-server preset.');
19
+ }
20
+ const routes = nuxt.options.routeRules;
21
+ for (const path of ['/api', '/api/**', '/api/*']) {
22
+ if (routes[path]?.proxy || routes[path]?.redirect) {
23
+ throw new Error(`Remove the conflicting ${path} proxy/redirect: Orionplace handles API routing.`);
24
+ }
25
+ }
26
+ if (nuxt.options.vite.server?.proxy?.['/api']) {
27
+ throw new Error('Remove the Vite /api proxy: Orionplace provides a server gateway in development too.');
28
+ }
29
+ // Do not inherit page caching for authenticated API requests.
30
+ routes['/api/**'] = { ...routes['/api/**'], cache: false, swr: false,
31
+ headers: { ...routes['/api/**']?.headers, 'cache-control': 'no-store' } };
32
+ // Values stay private to the server. MIMIC_FRONT_DOMAIN is also read at runtime.
33
+ nuxt.options.runtimeConfig.mimicFrontDomain ??= '';
34
+ nuxt.options.runtimeConfig.mimicFrontDomain = process.env.MIMIC_FRONT_DOMAIN
35
+ ?? nuxt.options.runtimeConfig.mimicFrontDomain;
36
+ nuxt.options.nitro.preset ||= 'node-server';
37
+ const vary = new Set(String(routes['/**']?.headers?.vary || '').split(',').map(x => x.trim()).filter(Boolean));
38
+ for (const header of ['Host', 'X-Forwarded-Host', 'X-Front-Domain']) vary.add(header);
39
+ routes['/**'] = { ...routes['/**'], headers: { ...routes['/**']?.headers, vary: [...vary].join(', ') } };
40
+ const configs = resolve(nuxt.options.rootDir, process.env.SITEMAPS_DIR || 'sitemaps');
41
+ const hosts = existsSync(configs) ? readdirSync(configs).filter(x => x.endsWith('.cfg')).map(x => normalizeFrontDomain(x.slice(0, -4))).filter(Boolean) : [];
42
+ nuxt.options.vite.server ||= {};
43
+ if (nuxt.options.vite.server.allowedHosts !== true) {
44
+ nuxt.options.vite.server.allowedHosts = [...new Set([...(nuxt.options.vite.server.allowedHosts || []), ...hosts])];
45
+ }
46
+ addServerHandler({ route: '/api/**', handler: resolve(directory, 'api.mjs') });
47
+ addServerHandler({ middleware: true, handler: resolve(directory, 'domain.mjs') });
48
+ },
49
+ });
@@ -0,0 +1,180 @@
1
+ export function projectReadme(settings) {
2
+ const frontend = settings.frontend || 'shop.example.com';
3
+ const backend = settings.backend || 'https://your-project.orionplace.app/api';
4
+ return `# Your Orionplace Nuxt integration
5
+
6
+ This folder contains the integration source for your Nuxt 4 storefront.
7
+ Your personal backend API is provided in your account at https://orionmarket.place.
8
+ You can change that backend domain in your account, then update this project.
9
+
10
+ ## Current setup
11
+
12
+ - Frontend domains: ${settings.frontends?.length ? settings.frontends.map(domain => '\`' + domain + '\`').join(', ') : 'not configured yet'}
13
+ - Local MIMIC domain: \`${settings.frontend || 'not configured yet'}\`
14
+ - Personal API: \`${settings.backend || 'not configured yet'}\`
15
+
16
+ Set or change these values from the project root:
17
+
18
+ \`\`\`bash
19
+ npx orionplace-nuxt set backend ${backend}
20
+ npx orionplace-nuxt add frontend ${frontend}
21
+ npx orionplace-nuxt add frontend www.${frontend}
22
+ npx orionplace-nuxt remove frontend www.${frontend}
23
+ \`\`\`
24
+
25
+ Set the backend or add a frontend first; either order works. Each frontend has its
26
+ own \`sitemaps/<domain>.cfg\` mapping to your personal API. Adding a domain again
27
+ is safe. Changing the backend updates every CLI-managed frontend mapping.
28
+
29
+ The first added frontend becomes \`MIMIC_FRONT_DOMAIN\` in \`.env\`. Adding more
30
+ domains preserves that choice. Use \`npx orionplace-nuxt set frontend <domain>\`
31
+ to add and select a different local domain.
32
+
33
+ Removing a frontend deletes only its unchanged generated \`.cfg\` file. If it was
34
+ the selected MIMIC domain, the first remaining domain takes its place. Removing
35
+ the last frontend clears MIMIC; add a frontend again before making API requests.
36
+ Unknown or manually edited mappings are not removed automatically.
37
+
38
+ Restart a running development server after changing domains so Vite reloads its
39
+ allowed hosts and Nuxt reloads \`.env\`. Production reads domain files on each
40
+ request; restart or reload the process environment when changing MIMIC. DNS, TLS
41
+ and hosting-proxy settings must also cover each public frontend domain.
42
+ Existing unrelated environment variables and Nuxt configuration are preserved.
43
+
44
+ ## Files you can read and customize
45
+
46
+ | File | Purpose |
47
+ | --- | --- |
48
+ | \`runtime/marketplace.mjs\` | Domain lookup, configuration validation, API transport and cookies. |
49
+ | \`runtime/api.mjs\` | The Nuxt/Nitro HTTP handler for \`/api/**\`. |
50
+ | \`runtime/domain.mjs\` | Resolves the frontend domain into the request context. |
51
+ | \`settings.json\` | The CLI's frontend list, local MIMIC domain and personal backend; not a secret store. |
52
+ | \`../sitemaps/${frontend}.cfg\` | The backend URL used for this frontend domain. |
53
+ | \`../.env\` | Local MIMIC setting; keep secrets out of version control. |
54
+ | \`../nuxt.config.ts\` | Registers the \`orionplace-nuxt\` module. |
55
+
56
+ The module registers these local source files with Nitro. You can edit them;
57
+ subsequent setup runs preserve customized runtime files and this README.
58
+ The CLI refuses to overwrite an unknown or manually changed domain mapping.
59
+
60
+ ## How requests reach your personal API
61
+
62
+ The trusted hosting proxy supplies the public domain through \`X-Front-Domain\`,
63
+ \`X-Forwarded-Host\`, or \`Host\`, in that order. On localhost,
64
+ \`MIMIC_FRONT_DOMAIN\` supplies the domain instead. A known public domain takes
65
+ priority over MIMIC; an unconfigured public domain returns \`503\`.
66
+
67
+ The domain file is read for each request. The proxy preserves the request method,
68
+ query, body, authorization and cookies. Backend HTTP status codes are preserved.
69
+ Backend cookie domains are removed so cookies belong to the active frontend.
70
+ Responses are not cached by this gateway. Network failures return \`502\`.
71
+
72
+ The configuration directory defaults to \`sitemaps\` under the process working
73
+ directory. Set \`SITEMAPS_DIR\` only if you store it elsewhere. Runtime configuration
74
+ does not set a fixed public site URL. The middleware exposes the active domain
75
+ as \`event.context.marketplaceDomain\`; use it when implementing your page metadata.
76
+
77
+ ## Start and verify
78
+
79
+ Use Node.js 22.12 or newer. After scaffolding a new project, install dependencies:
80
+
81
+ \`\`\`bash
82
+ npm install
83
+ npm run dev
84
+ \`\`\`
85
+
86
+ In another terminal:
87
+
88
+ \`\`\`bash
89
+ curl -i http://localhost:3000/api/appload \\
90
+ -H 'Content-Type: application/json' \\
91
+ --data '{"ignore_cache":true,"detalize_mode":"full"}'
92
+ \`\`\`
93
+
94
+ Check both the HTTP status and \`form_errors\`. Successful initialization returns
95
+ \`page_data\`; HTTP 200 by itself is insufficient. Documentation and public requests
96
+ do not require a login prompt. Protected actions still require backend permission.
97
+
98
+ Browser requests use the storefront's own API path:
99
+
100
+ \`\`\`javascript
101
+ const response = await fetch('/api/appload', {
102
+ method: 'POST',
103
+ credentials: 'same-origin',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({ ignore_cache: true, detalize_mode: 'full' }),
106
+ });
107
+ const result = await response.json();
108
+ console.log(response.status, result);
109
+ \`\`\`
110
+
111
+ For SSR, use Nuxt's \`useRequestFetch()\` so the request's domain, cookie and
112
+ authorization headers reach the same gateway. For example, in \`app/app.vue\`:
113
+
114
+ \`\`\`vue
115
+ <script setup>
116
+ const requestFetch = useRequestFetch();
117
+ const { data, error } = await useAsyncData('marketplace-initialization', () =>
118
+ requestFetch('/api/appload', {
119
+ method: 'POST',
120
+ body: { ignore_cache: true, detalize_mode: 'full' },
121
+ }),
122
+ );
123
+ </script>
124
+
125
+ <template>
126
+ <main>
127
+ <p v-if="error">The marketplace API is currently unavailable.</p>
128
+ <pre v-else>{{ data }}</pre>
129
+ </main>
130
+ </template>
131
+ \`\`\`
132
+
133
+ Keep private data out of shared caches. This SSR example forwards incoming cookies;
134
+ it does not relay new upstream cookies to the browser. Make session-changing calls
135
+ from the browser, or explicitly append each returned Set-Cookie header to the SSR
136
+ response in your server integration.
137
+
138
+
139
+ ## Deploy on your VPS
140
+
141
+ This integration needs a Node.js server and its domain configuration files.
142
+ It does not support static-only or edge hosting. Deploy the build, dependencies,
143
+ \`sitemaps\`, and your environment configuration together; use an absolute
144
+ \`SITEMAPS_DIR\` when running from another working directory.
145
+
146
+ \`\`\`bash
147
+ npm run build
148
+ HOST=127.0.0.1 PORT=3000 node --env-file=.env .output/server/index.mjs
149
+ \`\`\`
150
+
151
+ The production process explicitly reads \`MIMIC_FRONT_DOMAIN\`. A public domain
152
+ still takes precedence. The Node port must stay private behind your HTTPS proxy.
153
+
154
+ Inside the storefront's HTTPS Nginx server block:
155
+
156
+ \`\`\`nginx
157
+ location / {
158
+ proxy_pass http://127.0.0.1:3000;
159
+ proxy_http_version 1.1;
160
+ proxy_set_header Host $host;
161
+ proxy_set_header X-Forwarded-Host $host;
162
+ proxy_set_header X-Front-Domain $host;
163
+ proxy_set_header X-Forwarded-Proto $scheme;
164
+ proxy_set_header X-Init-Ip $remote_addr;
165
+ proxy_buffering off;
166
+ }
167
+ \`\`\`
168
+
169
+ Here localhost is your frontend process on your VPS. That process selects the
170
+ remote personal API from the domain file. The proxy must overwrite those headers.
171
+ An unknown domain never falls back to another tenant. This setup does not generate
172
+ sitemap XML or add analytics.
173
+
174
+ ## More documentation
175
+
176
+ - [Nuxt project setup](https://orionmarket.place/sandbox/new-project/nuxt)
177
+ - [Domain setup and direct Nginx alternative](https://orionmarket.place/sandbox/backend-domain/nuxt)
178
+ - [AppLoad](https://orionmarket.place/sandbox)
179
+ `;
180
+ }
package/src/setup.mjs ADDED
@@ -0,0 +1,188 @@
1
+ import { readFile, readdir, lstat, mkdir, writeFile, rename, unlink } from 'node:fs/promises';
2
+ import { resolve, dirname, relative, sep } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { parseModule, generateCode } from 'magicast';
5
+ import { backendUrl, frontendDomain } from './validation.mjs';
6
+ import { projectReadme } from './project-readme.mjs';
7
+
8
+ const metadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
9
+ const hash = text => createHash('sha256').update(text).digest('hex');
10
+ async function read(path) {
11
+ try { return await readFile(path, 'utf8'); }
12
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
13
+ }
14
+ async function safePath(root, name) {
15
+ const target = resolve(root, name);
16
+ if (relative(root, target).startsWith('..') || target === root) throw new Error('Invalid generated path');
17
+ let cursor = root;
18
+ for (const piece of relative(root, target).split(sep)) {
19
+ cursor = resolve(cursor, piece);
20
+ try { if ((await lstat(cursor)).isSymbolicLink()) throw new Error(`Refusing symbolic link: ${name}`); }
21
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
22
+ }
23
+ return target;
24
+ }
25
+ function configureNuxt(source) {
26
+ try {
27
+ const module = parseModule(source);
28
+ const options = module.exports.default.$type === 'function-call'
29
+ ? module.exports.default.$args[0] : module.exports.default;
30
+ if (!options || options.$type !== 'object') throw new Error('Dynamic configuration');
31
+ if (options.nitro?.preset && options.nitro.preset !== 'node-server') throw new Error('Unsupported Nitro preset');
32
+ if (options.srcDir || options.serverDir || options.extends) throw new Error('Custom directories or layers');
33
+ for (const route of ['/api', '/api/**', '/api/*']) {
34
+ if (options.routeRules?.[route]?.proxy || options.routeRules?.[route]?.redirect) throw new Error('Conflicting API routing');
35
+ }
36
+ if (options.vite?.server?.proxy?.['/api']) throw new Error('Conflicting Vite proxy');
37
+ options.modules ||= [];
38
+ if (!Array.from(options.modules).some(item => item === 'orionplace-nuxt' || (Array.isArray(item) && item[0] === 'orionplace-nuxt'))) {
39
+ options.modules.push('orionplace-nuxt');
40
+ }
41
+ return generateCode(module).code + '\n';
42
+ } catch {
43
+ throw new Error('Cannot safely update this Nuxt configuration. Use a standard Nuxt 4 root config with a literal modules array and no custom srcDir/serverDir, layers, non-Node presets, or /api proxy. No files were changed.');
44
+ }
45
+ }
46
+ function updateEnvironment(source, domain) {
47
+ const lines = (source || '').split(/\r?\n/);
48
+ let updated = false;
49
+ const result = lines.flatMap(line => {
50
+ if (!/^\s*(?:export\s+)?MIMIC_FRONT_DOMAIN\s*=/.test(line)) return [line];
51
+ if (updated) return [];
52
+ updated = true;
53
+ return [`MIMIC_FRONT_DOMAIN="${domain}"`];
54
+ });
55
+ if (!updated) result.push(`MIMIC_FRONT_DOMAIN="${domain}"`);
56
+ return result.join('\n').replace(/^\n/, '').replace(/\n*$/, '\n');
57
+ }
58
+
59
+ export async function setupProject({ cwd = process.cwd(), action = 'set', field, value, dryRun = false } = {}) {
60
+ if (!['set', 'add', 'remove'].includes(action) || (action !== 'set' && field !== 'frontend')) {
61
+ throw new Error('Use set backend, add frontend, remove frontend, or set frontend.');
62
+ }
63
+ const root = resolve(cwd);
64
+ const setting = field === 'backend' ? backendUrl(value) : field === 'frontend' ? frontendDomain(value) : null;
65
+ if (field && !setting) throw new Error('Choose backend or frontend.');
66
+ const names = ['package.json', 'nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs', 'orionplace/settings.json', 'orionplace/.generated.json', '.env', '.gitignore'];
67
+ const current = {};
68
+ for (const name of names) current[name] = await read(await safePath(root, name));
69
+ const pkg = current['package.json'] ? JSON.parse(current['package.json']) : { name: 'orionplace-storefront', private: true, type: 'module' };
70
+ const dependencies = { ...pkg.devDependencies, ...pkg.dependencies };
71
+ if (!dependencies.nuxt && ['next', '@sveltejs/kit'].some(name => dependencies[name])) throw new Error('This package sets up Nuxt projects only.');
72
+ if (dependencies.nuxt && !/^(?:[~^]|>=)?4(?:\.|$)/.test(dependencies.nuxt)) {
73
+ throw new Error('This release supports Nuxt 4. Upgrade your Nuxt dependency before setup.');
74
+ }
75
+ const configs = ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs'].filter(name => current[name] !== null);
76
+ if (configs.length > 1) throw new Error('Multiple Nuxt configuration files found; keep one root config.');
77
+ const configName = configs[0] || 'nuxt.config.ts';
78
+ const config = configureNuxt(current[configName] || 'export default defineNuxtConfig({ modules: [] });\n');
79
+ for (const directory of ['server/api', 'server/routes/api']) {
80
+ const path = await safePath(root, directory);
81
+ let entries = [];
82
+ try { entries = await readdir(path); }
83
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
84
+ if (entries.some(name => name.startsWith('[...'))) {
85
+ throw new Error(`Existing API catch-all in ${directory}. Resolve this route conflict first.`);
86
+ }
87
+ }
88
+ const settings = current['orionplace/settings.json'] ? JSON.parse(current['orionplace/settings.json']) : {};
89
+ const previous = current['orionplace/.generated.json'] ? JSON.parse(current['orionplace/.generated.json']) : {};
90
+ if (settings.backend) settings.backend = backendUrl(settings.backend);
91
+ if (settings.frontend) settings.frontend = frontendDomain(settings.frontend);
92
+ const oldFrontend = settings.frontend;
93
+ // Migrate the initial single-domain format, including its previously generated mappings.
94
+ const legacyDomains = Object.keys(previous)
95
+ .map(name => /^sitemaps\/([^/]+)\.cfg$/.exec(name)?.[1]).filter(Boolean);
96
+ const frontends = settings.frontends ?? [settings.frontend, ...legacyDomains].filter(Boolean);
97
+ if (!Array.isArray(frontends)) throw new Error('settings.frontends must be an array of domain names.');
98
+ settings.frontends = [...new Set(frontends.map(frontendDomain))];
99
+ if (settings.frontend && !settings.frontends.includes(settings.frontend)) settings.frontends.unshift(settings.frontend);
100
+ if (field === 'backend') settings.backend = setting;
101
+ if (field === 'frontend') {
102
+ if (action === 'remove') {
103
+ if (!settings.frontends.includes(setting)) throw new Error(`Frontend is not managed by this project: ${setting}`);
104
+ settings.frontends = settings.frontends.filter(domain => domain !== setting);
105
+ if (settings.frontend === setting) settings.frontend = settings.frontends[0];
106
+ } else {
107
+ if (!settings.frontends.includes(setting)) settings.frontends.push(setting);
108
+ // Adding an alias preserves the local development domain. "set" selects it.
109
+ if (action === 'set' || !settings.frontend) settings.frontend = setting;
110
+ }
111
+ }
112
+ settings.frontend ||= settings.frontends[0];
113
+ if (!settings.frontend) delete settings.frontend;
114
+ const manifest = { ...previous };
115
+ const changes = new Map();
116
+ const preserved = [];
117
+ async function plan(name, content, { managed = false, preserve = false } = {}) {
118
+ const path = await safePath(root, name);
119
+ const existing = await read(path);
120
+ if (managed && existing !== null && existing !== content && previous[name] !== hash(existing)) {
121
+ if (preserve && previous[name]) { preserved.push(name); return; }
122
+ throw new Error(`Refusing to overwrite ${name}: it is not an unchanged generated file. No files were changed.`);
123
+ }
124
+ if (existing !== content) changes.set(name, content);
125
+ if (managed) manifest[name] = hash(content);
126
+ }
127
+ async function planRemoval(name) {
128
+ const existing = await read(await safePath(root, name));
129
+ if (existing !== null && previous[name] !== hash(existing)) {
130
+ throw new Error(`Refusing to remove ${name}: it is not an unchanged generated file. No files were changed.`);
131
+ }
132
+ if (existing !== null) changes.set(name, null);
133
+ delete manifest[name];
134
+ }
135
+ // All checks and content generation happen before the first write.
136
+ await plan(configName, config);
137
+ const isNewNuxt = !dependencies.nuxt;
138
+ pkg.scripts ||= {};
139
+ pkg.scripts.dev ||= 'nuxt dev';
140
+ pkg.scripts.build ||= 'nuxt build';
141
+ pkg.scripts.preview ||= 'nuxt preview';
142
+ pkg.scripts['orionplace:backend'] ||= 'orionplace-nuxt set backend';
143
+ pkg.scripts['orionplace:frontend'] ||= 'orionplace-nuxt set frontend';
144
+ pkg.scripts['orionplace:add-frontend'] ||= 'orionplace-nuxt add frontend';
145
+ pkg.scripts['orionplace:remove-frontend'] ||= 'orionplace-nuxt remove frontend';
146
+ pkg.dependencies ||= {};
147
+ pkg.dependencies['orionplace-nuxt'] ||= dependencies['orionplace-nuxt'] || `^${metadata.version}`;
148
+ if (isNewNuxt) {
149
+ pkg.dependencies.nuxt = '^4.5.0';
150
+ pkg.dependencies.vue ||= '^3.5.0';
151
+ pkg.dependencies['vue-router'] ||= '^5.0.0';
152
+ }
153
+ await plan('package.json', JSON.stringify(pkg, null, 2) + '\n');
154
+ for (const file of ['marketplace.mjs', 'api.mjs', 'domain.mjs']) {
155
+ await plan(`orionplace/runtime/${file}`, await readFile(new URL(`../runtime/${file}`, import.meta.url), 'utf8'), { managed: true, preserve: true });
156
+ }
157
+ await plan('orionplace/README.md', projectReadme(settings), { managed: true, preserve: true });
158
+ if (isNewNuxt && !(await read(await safePath(root, 'app/app.vue')))) {
159
+ await plan('app/app.vue', '<template>\n <main>\n <h1>Your marketplace</h1>\n <p>Domain setup and API instructions are in orionplace/README.md.</p>\n </main>\n</template>\n', { managed: true });
160
+ }
161
+ if (settings.frontend || oldFrontend) await plan('.env', updateEnvironment(current['.env'], settings.frontend || ''));
162
+ if (settings.backend) {
163
+ const mappings = action === 'remove' ? []
164
+ : field === 'frontend' ? [setting] : settings.frontends;
165
+ for (const domain of mappings) {
166
+ await plan(`sitemaps/${domain}.cfg`, settings.backend + '\n', { managed: true });
167
+ }
168
+ }
169
+ if (action === 'remove') await planRemoval(`sitemaps/${setting}.cfg`);
170
+ let ignore = current['.gitignore'] || '';
171
+ for (const entry of ['node_modules/', '.nuxt/', '.output/', '.env', '.env.*', '!.env.example']) {
172
+ if (!ignore.split(/\r?\n/).includes(entry)) ignore = ignore.replace(/\n*$/, '\n') + entry + '\n';
173
+ }
174
+ await plan('.gitignore', ignore);
175
+ await plan('orionplace/settings.json', JSON.stringify(settings, null, 2) + '\n');
176
+ await plan('orionplace/.generated.json', JSON.stringify(manifest, null, 2) + '\n');
177
+ if (!dryRun) {
178
+ for (const [name, content] of changes) {
179
+ const target = await safePath(root, name);
180
+ if (content === null) { await unlink(target); continue; }
181
+ await mkdir(dirname(target), { recursive: true });
182
+ const temporary = `${target}.orionplace-${process.pid}.tmp`;
183
+ await writeFile(temporary, content, { mode: name === '.env' ? 0o600 : 0o644, flag: 'wx' });
184
+ await rename(temporary, target);
185
+ }
186
+ }
187
+ return { root, settings, changed: [...changes.keys()], removed: [...changes].filter(([, content]) => content === null).map(([name]) => name), preserved, dryRun, isNewNuxt };
188
+ }
@@ -0,0 +1,27 @@
1
+ import { isIP } from 'node:net';
2
+ const dns = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
3
+
4
+ export function frontendDomain(value) {
5
+ let url;
6
+ try { url = new URL(value.includes('://') ? value : `https://${value}`); }
7
+ catch { throw new Error('Use a public frontend hostname, for example shop.example.com.'); }
8
+ const domain = url.hostname.toLowerCase().replace(/\.$/, '');
9
+ if (!dns.test(domain) || isIP(domain) || url.username || url.password || url.port ||
10
+ url.search || url.hash || url.pathname !== '/' || !['http:', 'https:'].includes(url.protocol)) {
11
+ throw new Error('Frontend must be a public hostname without a port, path, or credentials.');
12
+ }
13
+ return domain;
14
+ }
15
+
16
+ export function backendUrl(value) {
17
+ let url;
18
+ try { url = new URL(value.includes('://') ? value : `https://${value}`); }
19
+ catch { throw new Error('Use the personal backend domain shown in your Orionplace account.'); }
20
+ const path = url.pathname.replace(/\/+$/, '');
21
+ if (url.protocol !== 'https:' || !dns.test(url.hostname) ||
22
+ !url.hostname.endsWith('.orionplace.app') || url.username || url.password ||
23
+ url.port || url.search || url.hash || !['', '/api'].includes(path)) {
24
+ throw new Error('Backend must be https://<your-project>.orionplace.app or its /api URL.');
25
+ }
26
+ return `${url.origin}/api`;
27
+ }