Skip to content

Commit fb063e8

Browse files
Domantasclaude
andcommitted
feat(env): norbix env admin-url set/clear/show — one admin portal per environment; project --env shows the effective admin URL
Gateway N49: PATCH/GET /v3/account/projects/environments/{environmentName}/admin-url, called directly (the SDK does not know the routes yet), env from --env / --prod / the saved default, else PROD. set/clear read the answer back and print where the env's reset / magic-link / invitation e-mails point and why (environment / project / hosted / cloud). `norbix project --env TEST` adds environmentAdminUrl; without an env it answers as before. A 404/405 names an old hub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 42a9649 commit fb063e8

9 files changed

Lines changed: 540 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ norbix scheduler list
4545
| `norbix users list/get/invite/block/unblock/delete` | Manage project users (membership) |
4646
| `norbix membership password reset request <email>` | Admin: send a test password-reset e-mail in `--env`; says whether a login was found, which integration sends it, and (from the hub's project-log entry) whether it went out, through which integration and template |
4747
| `norbix env list/use/delete/promote/rollback`, `norbix env built-ins repair` | Manage project environments and move content TEST → PROD (see *Environments* below) |
48+
| `norbix env admin-url set/clear/show` | The admin portal address one environment's reset / magic-link / invitation e-mails open (gateway N49); `norbix project --env <ENV>` shows it too, with its source |
4849
| `norbix logs list/trail/get` | Read project logs, follow one correlation ID, show one entry |
4950
| `norbix logs write` | Write your app's own lines (`--title …` or `--file entries.json`); prints `accepted: n`, on a 429 says how many seconds to wait |
5051
| `norbix logs settings` / `settings set` / `clean` | Read and change log settings (only the flags you pass are sent); delete every stored entry (asks; `--yes` skips) |
@@ -304,6 +305,26 @@ runs, and stderr says:
304305
Note: PROD is promoted from TEST — make the change in TEST and run `norbix env promote --from TEST --to PROD`
305306
```
306307

308+
#### One admin portal per environment
309+
310+
When each environment runs its own admin portal (the portal image started with
311+
`ENV=TEST` on `test-admin.…` and `ENV=PROD` on `admin.…`), give each environment
312+
its address. Password-reset, magic-link and invitation e-mails sent in that
313+
environment then open its own portal, which confirms in the same environment:
314+
315+
```sh
316+
norbix env admin-url set https://admin.example.com --prod
317+
norbix env admin-url set https://test-admin.example.com --env TEST
318+
norbix env admin-url show --env TEST # TEST: e-mail links open https://test-admin.example.com — from its own admin URL
319+
norbix project --env TEST # the project, plus environmentAdminUrl {adminUrl, effectiveAdminUrl, source}
320+
norbix env admin-url clear --env TEST # back to the project admin URL, then the Norbix-hosted portal
321+
```
322+
323+
An environment without its own address uses the project admin URL
324+
(`project admin-portal set-url`), then the Norbix-hosted portal. The address's
325+
origin is allowed through CORS automatically while it is set; promotion never
326+
copies it.
327+
307328
## JSON output
308329

309330
Every command supports `--json` and prints clean JSON on stdout, so you can
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {BaseCommand} from '../../../base.js'
2+
import {EnvAdminUrlCommand} from '../../../lib/env-admin-url-command.js'
3+
4+
export default class EnvAdminUrlClear extends EnvAdminUrlCommand {
5+
static description = `Remove the --env environment's own admin portal address
6+
7+
Its e-mail links then use the project admin URL (\`project admin-portal set-url\`),
8+
then the Norbix-hosted portal. Its origin leaves CORS again.`
9+
10+
static examples = [
11+
'<%= config.bin %> env admin-url clear --env TEST',
12+
'<%= config.bin %> env admin-url clear --env TEST --dry-run',
13+
]
14+
15+
static flags = {
16+
...BaseCommand.dryRunFlags,
17+
}
18+
19+
async run(): Promise<unknown> {
20+
const {flags} = await this.parse(EnvAdminUrlClear)
21+
return this.writeAdminUrl(flags, '')
22+
}
23+
}

‎src/commands/env/admin-url/set.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {Args} from '@oclif/core'
2+
3+
import {BaseCommand} from '../../../base.js'
4+
import {EnvAdminUrlCommand} from '../../../lib/env-admin-url-command.js'
5+
6+
export default class EnvAdminUrlSet extends EnvAdminUrlCommand {
7+
static description = `Give the --env environment its own admin portal address
8+
9+
Use it when each environment runs its own admin portal (the portal started with
10+
ENV=TEST on test-admin.…, ENV=PROD on admin.…). Password-reset, magic-link and
11+
invitation e-mails sent in that environment then open this address, so the
12+
portal confirms in the environment the link was made in. The address must be
13+
https with a host and an optional path (no query). Its origin is allowed
14+
through CORS automatically — do not add it to the project origins. Promotion
15+
never copies it. \`env admin-url clear\` goes back to the project admin URL.`
16+
17+
static examples = [
18+
'<%= config.bin %> env admin-url set https://test-admin.example.com --env TEST',
19+
'<%= config.bin %> env admin-url set https://admin.example.com --prod',
20+
'<%= config.bin %> env admin-url set https://test-admin.example.com --env TEST --dry-run',
21+
]
22+
23+
static args = {
24+
url: Args.string({required: true, description: 'Admin portal address, e.g. https://test-admin.example.com'}),
25+
}
26+
27+
static flags = {
28+
...BaseCommand.dryRunFlags,
29+
}
30+
31+
async run(): Promise<unknown> {
32+
const {args, flags} = await this.parse(EnvAdminUrlSet)
33+
return this.writeAdminUrl(flags, args.url)
34+
}
35+
}

‎src/commands/env/admin-url/show.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {EnvAdminUrlCommand} from '../../../lib/env-admin-url-command.js'
2+
3+
export default class EnvAdminUrlShow extends EnvAdminUrlCommand {
4+
static description = `Show where the --env environment's e-mail links point
5+
6+
Password-reset, magic-link and invitation e-mails open the environment's admin
7+
portal. The answer has the environment's own URL (adminUrl), the address the
8+
links really use (effectiveAdminUrl) and where it came from (source):
9+
environment (its own URL), project (the project admin URL), hosted (the
10+
Norbix-hosted portal) or cloud / none (the links cannot be opened).`
11+
12+
static examples = [
13+
'<%= config.bin %> env admin-url show --env TEST',
14+
'<%= config.bin %> env admin-url show --prod --json',
15+
]
16+
17+
async run(): Promise<unknown> {
18+
const {flags} = await this.parse(EnvAdminUrlShow)
19+
const item = await this.readAdminUrl(flags)
20+
this.show(item)
21+
return item
22+
}
23+
}

‎src/commands/project.ts‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import {Args} from '@oclif/core'
22

3+
import {describeEnvAdminUrl, type EnvironmentAdminUrl, envAdminUrlCall, sendEnvAdminUrlCall, targetEnv} from '../lib/env-admin-url.js'
34
import {ProjectCommand} from '../lib/project.js'
45

56
export default class Project extends ProjectCommand {
67
static description = `Show a project: name, languages, regions, CORS origins, admin portal, modules
78
89
Shows the configured project unless you pass another project ID. List every
9-
project of the account with \`account projects\`.`
10+
project of the account with \`account projects\`. With --env (or --prod) it also
11+
shows the admin portal address that environment's e-mail links open and where
12+
it came from (environmentAdminUrl: environment / project / hosted / cloud).`
1013

11-
static examples = ['<%= config.bin %> project', '<%= config.bin %> project 66b2f0a1c3d4e5f6a7b8c9d0 --json']
14+
static examples = [
15+
'<%= config.bin %> project',
16+
'<%= config.bin %> project --env TEST',
17+
'<%= config.bin %> project 66b2f0a1c3d4e5f6a7b8c9d0 --json',
18+
]
1219

1320
static args = {
1421
id: Args.string({description: 'Project ID (defaults to the configured project)'}),
@@ -20,6 +27,31 @@ project of the account with \`account projects\`.`
2027

2128
const res = await client.hub.account.getProject({projectId})
2229

30+
// N49 — the effective admin portal URL of the chosen environment. Only when
31+
// an env was asked for, so a plain `project` answers exactly as before.
32+
const ctx = await this.freshContext(flags)
33+
if (ctx.env && !flags['dry-run']) {
34+
const token = ctx.bearerToken ?? ctx.apiKey
35+
let environmentAdminUrl: EnvironmentAdminUrl | {env: string; error: string} | undefined
36+
if (token) {
37+
try {
38+
environmentAdminUrl = (await sendEnvAdminUrlCall(
39+
envAdminUrlCall({...ctx, projectId}, token, targetEnv(ctx), 'GET'),
40+
)).item
41+
} catch (error) {
42+
environmentAdminUrl = {env: targetEnv(ctx), error: (error as Error).message}
43+
}
44+
}
45+
46+
const out = {...(res as object), environmentAdminUrl}
47+
this.print(out)
48+
if (environmentAdminUrl && !('error' in environmentAdminUrl) && !this.jsonEnabled()) {
49+
process.stderr.write(`${describeEnvAdminUrl(environmentAdminUrl)}\n`)
50+
}
51+
52+
return out
53+
}
54+
2355
this.print(res)
2456
return res
2557
}

‎src/lib/env-admin-url-command.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {BaseCommand, type GlobalFlags} from '../base.js'
2+
import {
3+
describeEnvAdminUrl,
4+
type EnvironmentAdminUrl,
5+
envAdminUrlCall,
6+
sendEnvAdminUrlCall,
7+
targetEnv,
8+
} from './env-admin-url.js'
9+
10+
/** Base of `env admin-url set|clear|show`: the env is --env / --prod / the saved default, else PROD. */
11+
export abstract class EnvAdminUrlCommand extends BaseCommand {
12+
/** Reads the env's admin URL (own / effective / source). */
13+
protected async readAdminUrl(flags: GlobalFlags): Promise<EnvironmentAdminUrl> {
14+
const {ctx, token, env} = await this.target(flags)
15+
try {
16+
const item = (await sendEnvAdminUrlCall(envAdminUrlCall(ctx, token, env, 'GET'))).item
17+
if (item) return item
18+
} catch (error) {
19+
this.error((error as Error).message)
20+
}
21+
22+
this.error(`The hub answered no admin URL for ${env}.`)
23+
}
24+
25+
/** Sets (url) or clears ('') the env's admin URL, then reads it back. */
26+
protected async writeAdminUrl(flags: GlobalFlags, url: string): Promise<unknown> {
27+
const {ctx, token, env} = await this.target(flags)
28+
const call = envAdminUrlCall(ctx, token, env, 'PATCH', url)
29+
30+
if (flags['dry-run']) {
31+
return this.dryRun({
32+
method: url ? 'env admin-url set' : 'env admin-url clear',
33+
request: call.body,
34+
http: {method: call.method, url: call.url, headers: {...call.headers, Authorization: 'Bearer ***'}, body: call.body},
35+
})
36+
}
37+
38+
try {
39+
await sendEnvAdminUrlCall(call)
40+
} catch (error) {
41+
this.error((error as Error).message)
42+
}
43+
44+
// Read it back: the answer says where the links point now (and why).
45+
const now = await this.readAdminUrl(flags)
46+
this.show(now)
47+
return now
48+
}
49+
50+
protected show(item: EnvironmentAdminUrl): void {
51+
this.print(this.jsonEnabled() ? item : describeEnvAdminUrl(item))
52+
}
53+
54+
private async target(flags: GlobalFlags) {
55+
const ctx = await this.freshContext(flags)
56+
this.assertEndpoints(ctx)
57+
if (!ctx.projectId) this.error('No project ID configured. Run `norbix login` or pass --project.')
58+
const token = ctx.bearerToken ?? ctx.apiKey
59+
if (!token) this.error('Not authenticated. Run `norbix login` or pass --api-key.')
60+
return {ctx, token, env: targetEnv(ctx)}
61+
}
62+
}

‎src/lib/env-admin-url.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type {ResolvedContext} from '../base.js'
2+
3+
/**
4+
* The admin portal URL of one environment (gateway N49):
5+
* `GET|PATCH /v3/account/projects/environments/{environmentName}/admin-url`.
6+
* The SDK does not know these routes yet, so the commands call them directly
7+
* with the same headers the SDK transport sends.
8+
*/
9+
10+
/** One environment's admin portal URL as the hub answers it. */
11+
export interface EnvironmentAdminUrl {
12+
env: string
13+
/** The environment's own URL; absent = it uses the fallback. */
14+
adminUrl?: string
15+
/** The base its password-reset / magic-link / invitation e-mails open. */
16+
effectiveAdminUrl?: string
17+
/** environment / project / hosted / cloud / none */
18+
source: string
19+
/** Why a set value was skipped, or why the links cannot be opened. */
20+
note?: string
21+
}
22+
23+
export interface HubCall {
24+
method: 'GET' | 'PATCH'
25+
url: string
26+
headers: Record<string, string>
27+
body?: unknown
28+
}
29+
30+
interface HubAnswer {
31+
item?: EnvironmentAdminUrl
32+
responseStatus?: {isSuccess?: boolean; errors?: Array<{message?: string; errorCode?: string}>}
33+
}
34+
35+
/** The environment a command acts on: --env / --prod / the saved default, else PROD. */
36+
export function targetEnv(ctx: Pick<ResolvedContext, 'env'>): string {
37+
return (ctx.env ?? 'PROD').trim().toUpperCase()
38+
}
39+
40+
/** Builds the request; the caller checked that a project and a token are there. */
41+
export function envAdminUrlCall(
42+
ctx: ResolvedContext,
43+
token: string,
44+
env: string,
45+
method: 'GET' | 'PATCH',
46+
url?: string,
47+
): HubCall {
48+
const href = `${ctx.hubUrl.replace(/\/$/, '')}/v3/account/projects/environments/${encodeURIComponent(env)}/admin-url`
49+
const target = new URL(href)
50+
// The route has no {projectId} segment: the project rides in the query, as for the env list.
51+
target.searchParams.set('projectId', ctx.projectId!)
52+
const headers: Record<string, string> = {
53+
Accept: 'application/json',
54+
Authorization: `Bearer ${token}`,
55+
'norbix-project-id': ctx.projectId!,
56+
}
57+
if (method === 'PATCH') headers['Content-Type'] = 'application/json'
58+
if (ctx.accountId) headers['norbix-account-id'] = ctx.accountId
59+
if (ctx.region) headers['nb-region'] = ctx.region
60+
return method === 'PATCH'
61+
? {method, url: target.href, headers, body: {environmentName: env, url: url ?? ''}}
62+
: {method, url: target.href, headers}
63+
}
64+
65+
/** Sends the call; throws an Error with the hub's reason (and a hint on an old hub). */
66+
export async function sendEnvAdminUrlCall(call: HubCall): Promise<HubAnswer> {
67+
const res = await fetch(call.url, {
68+
method: call.method,
69+
headers: call.headers,
70+
body: call.body === undefined ? undefined : JSON.stringify(call.body),
71+
})
72+
const text = await res.text()
73+
let answer: HubAnswer = {}
74+
try {
75+
answer = text ? (JSON.parse(text) as HubAnswer) : {}
76+
} catch {
77+
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`)
78+
}
79+
80+
if (!res.ok || answer.responseStatus?.isSuccess === false) {
81+
const why = answer.responseStatus?.errors?.map((e) => [e.errorCode, e.message].filter(Boolean).join(' ')).join('; ')
82+
const old = res.status === 404 || res.status === 405
83+
throw new Error(
84+
`HTTP ${res.status}${why ? `: ${why}` : ''}${old && !why ? ' (this hub is older than the per-environment admin URL — upgrade it)' : ''}`,
85+
)
86+
}
87+
88+
return answer
89+
}
90+
91+
/** One line for people: where the env's e-mail links point and why. */
92+
export function describeEnvAdminUrl(a: EnvironmentAdminUrl): string {
93+
const from: Record<string, string> = {
94+
environment: 'its own admin URL',
95+
project: 'the project admin URL (no URL of its own)',
96+
hosted: 'the Norbix-hosted portal',
97+
cloud: 'the Cloud URL — the Cloud cannot open these links',
98+
none: 'nothing — the links are relative',
99+
}
100+
const line = `${a.env}: e-mail links open ${a.effectiveAdminUrl ?? '(no address)'} — from ${from[a.source] ?? a.source}`
101+
return a.note ? `${line}\n note: ${a.note}` : line
102+
}

0 commit comments

Comments
 (0)