Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ norbix scheduler list
| `norbix users list/get/invite/block/unblock/delete` | Manage project users (membership) |
| `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 |
| `norbix env list/use/delete/promote/rollback`, `norbix env built-ins repair` | Manage project environments and move content TEST → PROD (see *Environments* below) |
| `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 |
| `norbix logs list/trail/get` | Read project logs, follow one correlation ID, show one entry |
| `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 |
| `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) |
Expand Down Expand Up @@ -304,6 +305,26 @@ runs, and stderr says:
Note: PROD is promoted from TEST — make the change in TEST and run `norbix env promote --from TEST --to PROD`
```

#### One admin portal per environment

When each environment runs its own admin portal (the portal image started with
`ENV=TEST` on `test-admin.…` and `ENV=PROD` on `admin.…`), give each environment
its address. Password-reset, magic-link and invitation e-mails sent in that
environment then open its own portal, which confirms in the same environment:

```sh
norbix env admin-url set https://admin.example.com --prod
norbix env admin-url set https://test-admin.example.com --env TEST
norbix env admin-url show --env TEST # TEST: e-mail links open https://test-admin.example.com — from its own admin URL
norbix project --env TEST # the project, plus environmentAdminUrl {adminUrl, effectiveAdminUrl, source}
norbix env admin-url clear --env TEST # back to the project admin URL, then the Norbix-hosted portal
```

An environment without its own address uses the project admin URL
(`project admin-portal set-url`), then the Norbix-hosted portal. The address's
origin is allowed through CORS automatically while it is set; promotion never
copies it.

## JSON output

Every command supports `--json` and prints clean JSON on stdout, so you can
Expand Down
23 changes: 23 additions & 0 deletions src/commands/env/admin-url/clear.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {BaseCommand} from '../../../base.js'
import {EnvAdminUrlCommand} from '../../../lib/env-admin-url-command.js'

export default class EnvAdminUrlClear extends EnvAdminUrlCommand {
static description = `Remove the --env environment's own admin portal address

Its e-mail links then use the project admin URL (\`project admin-portal set-url\`),
then the Norbix-hosted portal. Its origin leaves CORS again.`

static examples = [
'<%= config.bin %> env admin-url clear --env TEST',
'<%= config.bin %> env admin-url clear --env TEST --dry-run',
]

static flags = {
...BaseCommand.dryRunFlags,
}

async run(): Promise<unknown> {
const {flags} = await this.parse(EnvAdminUrlClear)
return this.writeAdminUrl(flags, '')
}
}
35 changes: 35 additions & 0 deletions src/commands/env/admin-url/set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {Args} from '@oclif/core'

import {BaseCommand} from '../../../base.js'
import {EnvAdminUrlCommand} from '../../../lib/env-admin-url-command.js'

export default class EnvAdminUrlSet extends EnvAdminUrlCommand {
static description = `Give the --env environment its own admin portal address

Use it when each environment runs its own admin portal (the portal started with
ENV=TEST on test-admin.…, ENV=PROD on admin.…). Password-reset, magic-link and
invitation e-mails sent in that environment then open this address, so the
portal confirms in the environment the link was made in. The address must be
https with a host and an optional path (no query). Its origin is allowed
through CORS automatically — do not add it to the project origins. Promotion
never copies it. \`env admin-url clear\` goes back to the project admin URL.`

static examples = [
'<%= config.bin %> env admin-url set https://test-admin.example.com --env TEST',
'<%= config.bin %> env admin-url set https://admin.example.com --prod',
'<%= config.bin %> env admin-url set https://test-admin.example.com --env TEST --dry-run',
]

static args = {
url: Args.string({required: true, description: 'Admin portal address, e.g. https://test-admin.example.com'}),
}

static flags = {
...BaseCommand.dryRunFlags,
}

async run(): Promise<unknown> {
const {args, flags} = await this.parse(EnvAdminUrlSet)
return this.writeAdminUrl(flags, args.url)
}
}
23 changes: 23 additions & 0 deletions src/commands/env/admin-url/show.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {EnvAdminUrlCommand} from '../../../lib/env-admin-url-command.js'

export default class EnvAdminUrlShow extends EnvAdminUrlCommand {
static description = `Show where the --env environment's e-mail links point

Password-reset, magic-link and invitation e-mails open the environment's admin
portal. The answer has the environment's own URL (adminUrl), the address the
links really use (effectiveAdminUrl) and where it came from (source):
environment (its own URL), project (the project admin URL), hosted (the
Norbix-hosted portal) or cloud / none (the links cannot be opened).`

static examples = [
'<%= config.bin %> env admin-url show --env TEST',
'<%= config.bin %> env admin-url show --prod --json',
]

async run(): Promise<unknown> {
const {flags} = await this.parse(EnvAdminUrlShow)
const item = await this.readAdminUrl(flags)
this.show(item)
return item
}
}
36 changes: 34 additions & 2 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import {Args} from '@oclif/core'

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

export default class Project extends ProjectCommand {
static description = `Show a project: name, languages, regions, CORS origins, admin portal, modules

Shows the configured project unless you pass another project ID. List every
project of the account with \`account projects\`.`
project of the account with \`account projects\`. With --env (or --prod) it also
shows the admin portal address that environment's e-mail links open and where
it came from (environmentAdminUrl: environment / project / hosted / cloud).`

static examples = ['<%= config.bin %> project', '<%= config.bin %> project 66b2f0a1c3d4e5f6a7b8c9d0 --json']
static examples = [
'<%= config.bin %> project',
'<%= config.bin %> project --env TEST',
'<%= config.bin %> project 66b2f0a1c3d4e5f6a7b8c9d0 --json',
]

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

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

// N49 — the effective admin portal URL of the chosen environment. Only when
// an env was asked for, so a plain `project` answers exactly as before.
const ctx = await this.freshContext(flags)
if (ctx.env && !flags['dry-run']) {
const token = ctx.bearerToken ?? ctx.apiKey
let environmentAdminUrl: EnvironmentAdminUrl | {env: string; error: string} | undefined
if (token) {
try {
environmentAdminUrl = (await sendEnvAdminUrlCall(
envAdminUrlCall({...ctx, projectId}, token, targetEnv(ctx), 'GET'),
)).item
} catch (error) {
environmentAdminUrl = {env: targetEnv(ctx), error: (error as Error).message}
}
}

const out = {...(res as object), environmentAdminUrl}
this.print(out)
if (environmentAdminUrl && !('error' in environmentAdminUrl) && !this.jsonEnabled()) {
process.stderr.write(`${describeEnvAdminUrl(environmentAdminUrl)}\n`)
}

return out
}

this.print(res)
return res
}
Expand Down
62 changes: 62 additions & 0 deletions src/lib/env-admin-url-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {BaseCommand, type GlobalFlags} from '../base.js'
import {
describeEnvAdminUrl,
type EnvironmentAdminUrl,
envAdminUrlCall,
sendEnvAdminUrlCall,
targetEnv,
} from './env-admin-url.js'

/** Base of `env admin-url set|clear|show`: the env is --env / --prod / the saved default, else PROD. */
export abstract class EnvAdminUrlCommand extends BaseCommand {
/** Reads the env's admin URL (own / effective / source). */
protected async readAdminUrl(flags: GlobalFlags): Promise<EnvironmentAdminUrl> {
const {ctx, token, env} = await this.target(flags)
try {
const item = (await sendEnvAdminUrlCall(envAdminUrlCall(ctx, token, env, 'GET'))).item
if (item) return item
} catch (error) {
this.error((error as Error).message)
}

this.error(`The hub answered no admin URL for ${env}.`)
}

/** Sets (url) or clears ('') the env's admin URL, then reads it back. */
protected async writeAdminUrl(flags: GlobalFlags, url: string): Promise<unknown> {
const {ctx, token, env} = await this.target(flags)
const call = envAdminUrlCall(ctx, token, env, 'PATCH', url)

if (flags['dry-run']) {
return this.dryRun({
method: url ? 'env admin-url set' : 'env admin-url clear',
request: call.body,
http: {method: call.method, url: call.url, headers: {...call.headers, Authorization: 'Bearer ***'}, body: call.body},
})
}

try {
await sendEnvAdminUrlCall(call)
} catch (error) {
this.error((error as Error).message)
}

// Read it back: the answer says where the links point now (and why).
const now = await this.readAdminUrl(flags)
this.show(now)
return now
}

protected show(item: EnvironmentAdminUrl): void {
this.print(this.jsonEnabled() ? item : describeEnvAdminUrl(item))
}

private async target(flags: GlobalFlags) {
const ctx = await this.freshContext(flags)
this.assertEndpoints(ctx)
if (!ctx.projectId) this.error('No project ID configured. Run `norbix login` or pass --project.')
const token = ctx.bearerToken ?? ctx.apiKey
if (!token) this.error('Not authenticated. Run `norbix login` or pass --api-key.')
return {ctx, token, env: targetEnv(ctx)}
}
}
102 changes: 102 additions & 0 deletions src/lib/env-admin-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type {ResolvedContext} from '../base.js'

/**
* The admin portal URL of one environment (gateway N49):
* `GET|PATCH /v3/account/projects/environments/{environmentName}/admin-url`.
* The SDK does not know these routes yet, so the commands call them directly
* with the same headers the SDK transport sends.
*/

/** One environment's admin portal URL as the hub answers it. */
export interface EnvironmentAdminUrl {
env: string
/** The environment's own URL; absent = it uses the fallback. */
adminUrl?: string
/** The base its password-reset / magic-link / invitation e-mails open. */
effectiveAdminUrl?: string
/** environment / project / hosted / cloud / none */
source: string
/** Why a set value was skipped, or why the links cannot be opened. */
note?: string
}

export interface HubCall {
method: 'GET' | 'PATCH'
url: string
headers: Record<string, string>
body?: unknown
}

interface HubAnswer {
item?: EnvironmentAdminUrl
responseStatus?: {isSuccess?: boolean; errors?: Array<{message?: string; errorCode?: string}>}
}

/** The environment a command acts on: --env / --prod / the saved default, else PROD. */
export function targetEnv(ctx: Pick<ResolvedContext, 'env'>): string {
return (ctx.env ?? 'PROD').trim().toUpperCase()
}

/** Builds the request; the caller checked that a project and a token are there. */
export function envAdminUrlCall(
ctx: ResolvedContext,
token: string,
env: string,
method: 'GET' | 'PATCH',
url?: string,
): HubCall {
const href = `${ctx.hubUrl.replace(/\/$/, '')}/v3/account/projects/environments/${encodeURIComponent(env)}/admin-url`
const target = new URL(href)
// The route has no {projectId} segment: the project rides in the query, as for the env list.
target.searchParams.set('projectId', ctx.projectId!)
const headers: Record<string, string> = {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'norbix-project-id': ctx.projectId!,
}
if (method === 'PATCH') headers['Content-Type'] = 'application/json'
if (ctx.accountId) headers['norbix-account-id'] = ctx.accountId
if (ctx.region) headers['nb-region'] = ctx.region
return method === 'PATCH'
? {method, url: target.href, headers, body: {environmentName: env, url: url ?? ''}}
: {method, url: target.href, headers}
}

/** Sends the call; throws an Error with the hub's reason (and a hint on an old hub). */
export async function sendEnvAdminUrlCall(call: HubCall): Promise<HubAnswer> {
const res = await fetch(call.url, {
method: call.method,
headers: call.headers,
body: call.body === undefined ? undefined : JSON.stringify(call.body),
})
const text = await res.text()
let answer: HubAnswer = {}
try {
answer = text ? (JSON.parse(text) as HubAnswer) : {}
} catch {
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`)
}

if (!res.ok || answer.responseStatus?.isSuccess === false) {
const why = answer.responseStatus?.errors?.map((e) => [e.errorCode, e.message].filter(Boolean).join(' ')).join('; ')
const old = res.status === 404 || res.status === 405
throw new Error(
`HTTP ${res.status}${why ? `: ${why}` : ''}${old && !why ? ' (this hub is older than the per-environment admin URL — upgrade it)' : ''}`,
)
}

return answer
}

/** One line for people: where the env's e-mail links point and why. */
export function describeEnvAdminUrl(a: EnvironmentAdminUrl): string {
const from: Record<string, string> = {
environment: 'its own admin URL',
project: 'the project admin URL (no URL of its own)',
hosted: 'the Norbix-hosted portal',
cloud: 'the Cloud URL — the Cloud cannot open these links',
none: 'nothing — the links are relative',
}
const line = `${a.env}: e-mail links open ${a.effectiveAdminUrl ?? '(no address)'} — from ${from[a.source] ?? a.source}`
return a.note ? `${line}\n note: ${a.note}` : line
}
Loading
Loading