Harden hosted mobile proof setup

This commit is contained in:
rcourtman 2026-04-24 13:13:36 +01:00
parent 56bc13cb52
commit badda3781c
11 changed files with 891 additions and 42 deletions

View file

@ -373,6 +373,7 @@ func printCloudAuditReport(report *cloudcp.CloudAuditReport) {
func init() {
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(newCloudCmd())
rootCmd.AddCommand(newMobileProofCmd())
rootCmd.AddCommand(newTenantRuntimeCmd())
}

View file

@ -0,0 +1,361 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp"
cpDocker "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/docker"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/entitlements"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
cpstripe "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/stripe"
"github.com/spf13/cobra"
)
const defaultMobileProofOwnerEmail = "pulse-mobile-ga-proof@pulserelay.pro"
type mobileProofRuntime struct {
cfg *cloudcp.CPConfig
registry *registry.TenantRegistry
docker *cpDocker.Manager
provisioner *cpstripe.Provisioner
}
func newMobileProofCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "mobile-proof",
Short: "Operate disposable hosted workspaces for Pulse Mobile release proofs",
Long: "Operate disposable hosted workspaces for Pulse Mobile release proofs.\n\n" +
"These commands intentionally use the normal hosted workspace provisioner and refuse\n" +
"customer-shaped accounts or tenants unless explicitly overridden.",
}
cmd.AddCommand(newMobileProofCreateAccountCmd())
cmd.AddCommand(newMobileProofCreateWorkspaceCmd())
cmd.AddCommand(newMobileProofDeleteWorkspaceCmd())
return cmd
}
func newMobileProofCreateAccountCmd() *cobra.Command {
var accountID string
var displayName string
var allowNonProofAccount bool
cmd := &cobra.Command{
Use: "create-account",
Short: "Create a disposable MSP account for Pulse Mobile proof",
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := newMobileProofRuntime(cmd.Context())
if err != nil {
return err
}
defer rt.close()
accountID = strings.TrimSpace(accountID)
if accountID == "" {
generated, err := registry.GenerateAccountID()
if err != nil {
return fmt.Errorf("generate proof account id: %w", err)
}
accountID = generated
}
displayName = strings.TrimSpace(displayName)
if displayName == "" {
displayName = fmt.Sprintf("Pulse Mobile GA Proof Account %s", time.Now().UTC().Format("20060102T150405Z"))
}
account := &registry.Account{
ID: accountID,
Kind: registry.AccountKindMSP,
DisplayName: displayName,
}
if !allowNonProofAccount && !looksLikeMobileProofAccount(account) {
return fmt.Errorf("refusing to create non-proof account %s; display name or account id must contain proof/canary/mobile/rehearsal/seed", accountID)
}
if err := rt.registry.CreateAccount(account); err != nil {
return fmt.Errorf("create mobile proof account: %w", err)
}
fmt.Printf("account_id=%s\n", account.ID)
fmt.Printf("kind=%s\n", account.Kind)
fmt.Printf("display_name=%s\n", account.DisplayName)
return nil
},
}
cmd.Flags().StringVar(&accountID, "account-id", "", "Optional account ID; generated when omitted")
cmd.Flags().StringVar(&displayName, "display-name", "", "Proof account display name; defaults to a Pulse Mobile GA Proof Account timestamp")
cmd.Flags().BoolVar(&allowNonProofAccount, "allow-non-proof-account", false, "Allow creation of an account that does not look like a proof account")
return cmd
}
func newMobileProofCreateWorkspaceCmd() *cobra.Command {
var accountID string
var displayName string
var ownerEmail string
var allowNonProofAccount bool
cmd := &cobra.Command{
Use: "create-workspace",
Short: "Create a disposable hosted workspace for Pulse Mobile proof",
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := newMobileProofRuntime(cmd.Context())
if err != nil {
return err
}
defer rt.close()
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return fmt.Errorf("--account-id is required")
}
if strings.TrimSpace(displayName) == "" {
displayName = fmt.Sprintf("Pulse Mobile GA Proof %s", time.Now().UTC().Format("20060102T150405Z"))
}
ownerEmail = strings.ToLower(strings.TrimSpace(ownerEmail))
if ownerEmail == "" {
ownerEmail = defaultMobileProofOwnerEmail
}
account, err := rt.registry.GetAccount(accountID)
if err != nil {
return fmt.Errorf("load proof account %s: %w", accountID, err)
}
if account == nil {
return fmt.Errorf("account %s not found", accountID)
}
if !allowNonProofAccount && !looksLikeMobileProofAccount(account) {
return fmt.Errorf("refusing to create mobile proof workspace under non-proof account %s; pass --allow-non-proof-account only after confirming this is not a customer account", accountID)
}
if !strings.Contains(strings.ToLower(displayName), "proof") && !strings.Contains(strings.ToLower(displayName), "canary") {
return fmt.Errorf("--display-name must contain proof or canary")
}
tenant, err := rt.provisioner.ProvisionWorkspaceForOwner(cmd.Context(), accountID, displayName, ownerEmail)
if err != nil {
return fmt.Errorf("create mobile proof workspace: %w", err)
}
printMobileProofTenant(rt.cfg, tenant)
return nil
},
}
cmd.Flags().StringVar(&accountID, "account-id", "", "Existing proof account ID that owns the disposable workspace")
cmd.Flags().StringVar(&displayName, "display-name", "", "Proof workspace display name; defaults to a Pulse Mobile GA Proof timestamp")
cmd.Flags().StringVar(&ownerEmail, "owner-email", defaultMobileProofOwnerEmail, "Owner email to seed into the hosted workspace metadata")
cmd.Flags().BoolVar(&allowNonProofAccount, "allow-non-proof-account", false, "Allow creation under an account that does not look like a proof account")
return cmd
}
func newMobileProofDeleteWorkspaceCmd() *cobra.Command {
var tenantID string
var allowNonProofTenant bool
cmd := &cobra.Command{
Use: "delete-workspace",
Short: "Soft-delete a disposable hosted workspace created for Pulse Mobile proof",
RunE: func(cmd *cobra.Command, args []string) error {
rt, err := newMobileProofRuntime(cmd.Context())
if err != nil {
return err
}
defer rt.close()
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" {
return fmt.Errorf("--tenant-id is required")
}
tenant, err := rt.registry.Get(tenantID)
if err != nil {
return fmt.Errorf("load tenant %s: %w", tenantID, err)
}
if tenant == nil {
return fmt.Errorf("tenant %s not found", tenantID)
}
account, err := rt.registry.GetAccount(tenant.AccountID)
if err != nil {
return fmt.Errorf("load tenant account %s: %w", tenant.AccountID, err)
}
if !allowNonProofTenant && !looksLikeMobileProofTenant(tenant, account) {
return fmt.Errorf("refusing to delete non-proof tenant %s; pass --allow-non-proof-tenant only after confirming this is not a customer workspace", tenantID)
}
previousState := tenant.State
tenant.State = registry.TenantStateDeleting
if err := rt.registry.Update(tenant); err != nil {
return fmt.Errorf("mark tenant deleting: %w", err)
}
if err := rt.provisioner.DeprovisionWorkspaceContainer(cmd.Context(), tenant); err != nil {
return fmt.Errorf("deprovision tenant container: %w", err)
}
tenant.ContainerID = ""
tenant.State = registry.TenantStateDeleted
if err := rt.registry.Update(tenant); err != nil {
return fmt.Errorf("mark tenant deleted: %w", err)
}
fmt.Printf("tenant_id=%s\n", tenant.ID)
fmt.Printf("account_id=%s\n", tenant.AccountID)
fmt.Printf("previous_state=%s\n", previousState)
fmt.Printf("state=%s\n", tenant.State)
return nil
},
}
cmd.Flags().StringVar(&tenantID, "tenant-id", "", "Disposable proof tenant ID to delete")
cmd.Flags().BoolVar(&allowNonProofTenant, "allow-non-proof-tenant", false, "Allow deletion of a tenant that does not look like a proof tenant")
return cmd
}
func newMobileProofRuntime(ctx context.Context) (*mobileProofRuntime, error) {
cfg, err := cloudcp.LoadConfig()
if err != nil {
return nil, fmt.Errorf("load control plane config: %w", err)
}
if err := os.MkdirAll(cfg.TenantsDir(), 0o755); err != nil {
return nil, fmt.Errorf("create tenants dir: %w", err)
}
if err := os.MkdirAll(cfg.ControlPlaneDir(), 0o755); err != nil {
return nil, fmt.Errorf("create control-plane dir: %w", err)
}
reg, err := registry.NewTenantRegistry(cfg.ControlPlaneDir())
if err != nil {
return nil, fmt.Errorf("open tenant registry: %w", err)
}
dockerMgr, err := cpDocker.NewManager(cpDocker.ManagerConfig{
Image: cfg.PulseImage,
Network: cfg.DockerNetwork,
BaseDomain: mobileProofBaseDomainFromURL(cfg.BaseURL),
TrialActivationPublicKey: cfg.TrialActivationPublicKey,
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
MemoryLimit: cfg.TenantMemoryLimit,
CPUShares: cfg.TenantCPUShares,
TenantLogMaxSize: cfg.TenantLogMaxSize,
TenantLogMaxFile: cfg.TenantLogMaxFile,
})
if err != nil {
reg.Close()
return nil, fmt.Errorf("create docker manager: %w", err)
}
hostedEntitlements := entitlements.NewService(reg, cfg.BaseURL, cfg.TrialActivationPrivateKey)
provisioner := cpstripe.NewProvisioner(
reg,
cfg.TenantsDir(),
dockerMgr,
nil,
cfg.BaseURL,
nil,
cfg.EmailFrom,
cfg.AllowDockerlessProvisioning,
cpstripe.WithHostedEntitlementService(hostedEntitlements),
cpstripe.WithTrialActivationPrivateKey(cfg.TrialActivationPrivateKey),
cpstripe.WithAdmissionCheck(func(checkCtx context.Context) error {
return cloudcp.EnforceStorageAdmission(checkCtx, cfg, dockerMgr)
}),
)
return &mobileProofRuntime{
cfg: cfg,
registry: reg,
docker: dockerMgr,
provisioner: provisioner,
}, nil
}
func (rt *mobileProofRuntime) close() {
if rt == nil {
return
}
if rt.docker != nil {
rt.docker.Close()
}
if rt.registry != nil {
rt.registry.Close()
}
}
func looksLikeMobileProofAccount(account *registry.Account) bool {
if account == nil {
return false
}
return containsMobileProofMarker(account.ID, account.DisplayName) ||
strings.HasPrefix(strings.ToLower(strings.TrimSpace(account.ID)), "a_msp_") ||
strings.Contains(strings.ToLower(strings.TrimSpace(account.ID)), "ownerseed")
}
func looksLikeMobileProofTenant(tenant *registry.Tenant, account *registry.Account) bool {
if tenant == nil {
return false
}
return containsMobileProofMarker(
tenant.ID,
tenant.AccountID,
tenant.Email,
tenant.DisplayName,
tenant.StripeCustomerID,
tenant.StripeSubscriptionID,
tenant.StripePriceID,
tenant.PlanVersion,
) || looksLikeMobileProofAccount(account)
}
func containsMobileProofMarker(values ...string) bool {
for _, value := range values {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
continue
}
for _, marker := range []string{"mobile", "proof", "canary", "rehearsal", "seed"} {
if strings.Contains(normalized, marker) {
return true
}
}
}
return false
}
func mobileProofBaseDomainFromURL(baseURL string) string {
domain := strings.TrimSpace(baseURL)
for _, prefix := range []string{"https://", "http://"} {
if strings.HasPrefix(domain, prefix) {
domain = strings.TrimPrefix(domain, prefix)
break
}
}
if idx := strings.IndexAny(domain, ":/"); idx >= 0 {
domain = domain[:idx]
}
return domain
}
func printMobileProofTenant(cfg *cloudcp.CPConfig, tenant *registry.Tenant) {
if tenant == nil {
return
}
baseDomain := ""
if cfg != nil {
baseDomain = mobileProofBaseDomainFromURL(cfg.BaseURL)
}
publicURL := ""
if baseDomain != "" {
publicURL = fmt.Sprintf("https://%s.%s", strings.ToLower(tenant.ID), baseDomain)
}
fmt.Printf("tenant_id=%s\n", tenant.ID)
fmt.Printf("account_id=%s\n", tenant.AccountID)
fmt.Printf("display_name=%s\n", tenant.DisplayName)
fmt.Printf("state=%s\n", tenant.State)
fmt.Printf("plan_version=%s\n", tenant.PlanVersion)
fmt.Printf("container_id=%s\n", tenant.ContainerID)
if publicURL != "" {
fmt.Printf("public_url=%s\n", publicURL)
fmt.Printf("onboarding_url=%s/api/onboarding/qr\n", publicURL)
}
}

View file

@ -0,0 +1,138 @@
package main
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
)
func TestLooksLikeMobileProofAccount(t *testing.T) {
tests := []struct {
name string
account *registry.Account
want bool
}{
{
name: "nil",
account: nil,
want: false,
},
{
name: "explicit proof display",
account: &registry.Account{
ID: "a_live",
DisplayName: "Pulse Mobile Proof",
},
want: true,
},
{
name: "synthetic msp proof id",
account: &registry.Account{
ID: "a_msp_prod_fix_20260313111348",
DisplayName: "Pulse",
},
want: true,
},
{
name: "customer-shaped account",
account: &registry.Account{
ID: "a_W3PT2W0YFR",
DisplayName: "Customer Account",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := looksLikeMobileProofAccount(tt.account); got != tt.want {
t.Fatalf("looksLikeMobileProofAccount() = %t, want %t", got, tt.want)
}
})
}
}
func TestLooksLikeMobileProofTenant(t *testing.T) {
tests := []struct {
name string
tenant *registry.Tenant
account *registry.Account
want bool
}{
{
name: "nil",
tenant: nil,
want: false,
},
{
name: "proof display",
tenant: &registry.Tenant{
ID: "t-123",
DisplayName: "Pulse Mobile GA Proof",
},
want: true,
},
{
name: "proof account fallback",
tenant: &registry.Tenant{
ID: "t-123",
AccountID: "a_msp_prod_fix_20260313111348",
},
account: &registry.Account{
ID: "a_msp_prod_fix_20260313111348",
},
want: true,
},
{
name: "customer tenant",
tenant: &registry.Tenant{
ID: "t-123",
AccountID: "a_customer",
DisplayName: "Customer Workspace",
},
account: &registry.Account{
ID: "a_customer",
DisplayName: "Customer Account",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := looksLikeMobileProofTenant(tt.tenant, tt.account); got != tt.want {
t.Fatalf("looksLikeMobileProofTenant() = %t, want %t", got, tt.want)
}
})
}
}
func TestMobileProofBaseDomainFromURL(t *testing.T) {
tests := map[string]string{
"https://cloud.pulserelay.pro": "cloud.pulserelay.pro",
"http://cloud.pulserelay.pro:8080/x": "cloud.pulserelay.pro",
"cloud.pulserelay.pro/path": "cloud.pulserelay.pro",
" cloud.pulserelay.pro ": "cloud.pulserelay.pro",
}
for input, want := range tests {
t.Run(input, func(t *testing.T) {
if got := mobileProofBaseDomainFromURL(input); got != want {
t.Fatalf("mobileProofBaseDomainFromURL(%q) = %q, want %q", input, got, want)
}
})
}
}
func TestMobileProofCommandExposesAccountAndWorkspaceLifecycle(t *testing.T) {
cmd := newMobileProofCmd()
found := map[string]bool{}
for _, sub := range cmd.Commands() {
found[sub.Name()] = true
}
for _, name := range []string{"create-account", "create-workspace", "delete-workspace"} {
if !found[name] {
t.Fatalf("mobile-proof subcommand %q is not registered", name)
}
}
}

View file

@ -24,9 +24,10 @@ server-side update execution surfaces.
2. `internal/api/updates.go`
3. `frontend-modern/src/api/updates.ts`
4. `cmd/pulse-control-plane/main.go`
5. `internal/cloudcp/docker/manager.go`
6. `internal/cloudcp/docker/labels.go`
7. `internal/cloudcp/tenant_runtime_rollout.go`
5. `cmd/pulse-control-plane/mobile_proof_cmd.go`
6. `internal/cloudcp/docker/manager.go`
7. `internal/cloudcp/docker/labels.go`
8. `internal/cloudcp/tenant_runtime_rollout.go`
8. `.github/workflows/create-release.yml`
9. `.github/workflows/deploy-demo-server.yml`
10. `.github/workflows/helm-pages.yml`
@ -81,11 +82,13 @@ server-side update execution surfaces.
59. `tests/integration/README.md`
60. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
61. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
62. `tests/integration/scripts/hosted-tenant-runtime.mjs`
63. `tests/integration/scripts/managed-dev-runtime.mjs`
64. `tests/integration/scripts/relay-mobile-token-helper.go`
65. `tests/integration/tests/helpers.ts`
66. `tests/integration/tests/runtime-defaults.ts`
62. `tests/integration/scripts/hosted-tenant-approval-store.mjs`
63. `tests/integration/scripts/hosted-tenant-runtime.mjs`
64. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`
65. `tests/integration/scripts/managed-dev-runtime.mjs`
66. `tests/integration/scripts/relay-mobile-token-helper.go`
67. `tests/integration/tests/helpers.ts`
68. `tests/integration/tests/runtime-defaults.ts`
67. `docker-compose.yml`
68. `scripts/install-docker.sh`
69. `scripts/validate-published-release.sh`
@ -197,7 +200,12 @@ server-side update execution surfaces.
recorded Docker container is missing, dry-run must classify the tenant for
mutation and the live command must recreate the container, prove health, and
rewrite the registry runtime identity through the same control-plane path.
10. Add or change the canonical hosted staging smoke operator path through `scripts/run_hosted_staging_smoke.sh`, `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`, `tests/integration/scripts/hosted-mobile-token-runtime.mjs`, `tests/integration/scripts/hosted-tenant-runtime.mjs`, and `tests/integration/scripts/relay-mobile-token-helper.go`
10. Add or change the canonical hosted staging smoke operator path through `scripts/run_hosted_staging_smoke.sh`, `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`, `tests/integration/scripts/hosted-mobile-token-runtime.mjs`, `tests/integration/scripts/hosted-tenant-approval-store.mjs`, `tests/integration/scripts/hosted-tenant-runtime.mjs`, `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`, and `tests/integration/scripts/relay-mobile-token-helper.go`.
Hosted mobile proof helpers must create and delete only disposable
proof-shaped workspaces through the normal control-plane provisioner,
fetch onboarding payloads without logging bearer tokens or mobile deep-link
secrets, and seed hosted approvals through a single explicit tenant runtime
restart when a release proof needs transactionally visible approval state.
## Forbidden Paths

View file

@ -4,6 +4,8 @@ import (
"context"
"crypto/ed25519"
"encoding/base64"
"net/http/httptest"
"net/url"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
@ -81,6 +83,36 @@ func TestLoadRelayConfigForRuntime_DoesNotOverrideExplicitDisabledRelayConfig(t
}
}
func TestHostedRelayRuntimeBuildsMobileOnboardingAppRelayURL(t *testing.T) {
router, _, _ := newHostedRelayRuntimeTestRouter(t)
relayCfg, err := router.loadRelayConfigForRuntime(context.Background())
if err != nil {
t.Fatalf("loadRelayConfigForRuntime() error = %v", err)
}
req := httptest.NewRequest("GET", "https://t-hostedrelay01.cloud.pulserelay.pro/api/onboarding/qr", nil)
payload, diagnostics := router.buildOnboardingPayload(req, relayCfg, "test-mobile-proof-token")
if hasOnboardingError(diagnostics) {
t.Fatalf("onboarding diagnostics contain errors: %#v", diagnostics)
}
if payload.Relay.URL != "wss://relay.pulserelay.pro/ws/app" {
t.Fatalf("mobile onboarding relay URL = %q", payload.Relay.URL)
}
deepLink, err := url.Parse(payload.DeepLink)
if err != nil {
t.Fatalf("parse deep link: %v", err)
}
query := deepLink.Query()
if query.Get("relay_url") != payload.Relay.URL {
t.Fatalf("deep-link relay_url = %q, want %q", query.Get("relay_url"), payload.Relay.URL)
}
if query.Get("auth_token") != "test-mobile-proof-token" {
t.Fatalf("deep-link auth_token did not preserve the proof token")
}
}
func newHostedRelayRuntimeTestRouter(t *testing.T) (*Router, string, string) {
t.Helper()

View file

@ -369,6 +369,22 @@ func TestTenantRuntimeRollout_RecreatesMissingRuntimeFromTenantData(t *testing.T
}
}
func TestTenantRuntimeRollout_MobileProofTenantUsesCanonicalRuntimeContract(t *testing.T) {
tenantID := "t-MobileProof01"
docker := newFakeTenantRuntimeRolloutDocker()
routing := docker.DesiredRuntimeRouting(tenantID)
if got := tenantRuntimeContainerName(tenantID); got != "pulse-t-MobileProof01" {
t.Fatalf("tenant runtime container name = %q, want pulse-t-MobileProof01", got)
}
if routing.Host != "t-mobileproof01.cloud.pulserelay.pro" {
t.Fatalf("proof tenant route host = %q", routing.Host)
}
if routing.PublicURL != "https://t-mobileproof01.cloud.pulserelay.pro" {
t.Fatalf("proof tenant public URL = %q", routing.PublicURL)
}
}
func TestTenantRuntimeRollout_AdmissionFailureStopsBeforeSnapshotOrContainerSwap(t *testing.T) {
tenant := &registry.Tenant{ID: "t-ADMIT01", ContainerID: "old-container"}
reg := &fakeTenantRuntimeRolloutRegistry{tenant: tenant}

View file

@ -5,15 +5,17 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
restartHostedTenantRuntime,
shellQuote,
} from './hosted-tenant-runtime.mjs';
import { createHostedRelayMobileToken } from './hosted-mobile-token-runtime.mjs';
const DEFAULT_POLL_INTERVAL_MS = 500;
const DEFAULT_POLL_TIMEOUT_MS = 15_000;
const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..');
const SCRIPT_PATH = fileURLToPath(import.meta.url);
function usage(message) {
if (message) {
@ -96,16 +98,7 @@ function parseArgs(argv) {
return parsed;
}
function runText(command, args, options = {}) {
return execFileSync(command, args, {
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
stdio: 'pipe',
...options,
});
}
function deriveTenantBaseUrl(controlPlaneUrl, tenantId) {
export function deriveTenantBaseUrl(controlPlaneUrl, tenantId) {
const parsed = new URL(controlPlaneUrl);
parsed.hostname = `${tenantId}.${parsed.hostname}`;
parsed.pathname = '';
@ -114,24 +107,105 @@ function deriveTenantBaseUrl(controlPlaneUrl, tenantId) {
return parsed.toString().replace(/\/$/, '');
}
function curlJson(args) {
return JSON.parse(runText('curl', args));
export function redactBearerTokens(value) {
return String(value).replace(/Bearer\s+[A-Za-z0-9._~+/-]+/g, 'Bearer [REDACTED]');
}
function fetchOnboardingPayload({ rawToken, tenantBaseUrl }) {
return curlJson([
'-fsS',
'-H',
`Authorization: Bearer ${rawToken}`,
`${tenantBaseUrl}/api/onboarding/qr`,
]);
export async function fetchOnboardingPayload({ fetchImpl = globalThis.fetch, rawToken, tenantBaseUrl }) {
if (typeof fetchImpl !== 'function') {
throw new Error('fetch is not available in this Node runtime');
}
const url = `${tenantBaseUrl}/api/onboarding/qr`;
let response;
try {
response = await fetchImpl(url, {
headers: {
Authorization: `Bearer ${rawToken}`,
},
});
} catch (error) {
throw new Error(`failed to fetch hosted onboarding payload from ${tenantBaseUrl}: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`);
}
const body = await response.text();
if (!response.ok) {
throw new Error(`hosted onboarding payload request returned HTTP ${response.status} from ${tenantBaseUrl}: ${redactBearerTokens(body.slice(0, 500))}`);
}
try {
return JSON.parse(body);
} catch (error) {
throw new Error(`hosted onboarding payload from ${tenantBaseUrl} was not valid JSON: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`);
}
}
const REMOTE_ONBOARDING_FETCH_PY = `
import json
import sys
import urllib.error
import urllib.request
base_url = sys.argv[1].rstrip("/")
token = sys.stdin.read().strip()
request = urllib.request.Request(
base_url + "/api/onboarding/qr",
headers={"Authorization": "Bearer " + token},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
sys.stdout.write(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:500]
sys.stderr.write(json.dumps({"status": exc.code, "body": body}))
sys.exit(22)
except Exception as exc:
sys.stderr.write(str(exc))
sys.exit(1)
`;
export function fetchOnboardingPayloadViaCloudHost({
cloudHost,
rawToken,
runner = execFileSync,
tenantBaseUrl,
}) {
const host = String(cloudHost ?? '').trim();
if (!host) {
throw new Error('cloud host is required for hosted onboarding remote fetch');
}
const command = `python3 -c ${shellQuote(REMOTE_ONBOARDING_FETCH_PY)} ${shellQuote(tenantBaseUrl)}`;
let output;
try {
output = runner('ssh', [host, command], {
encoding: 'utf8',
input: rawToken,
maxBuffer: 32 * 1024 * 1024,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (error) {
const detail = ['stderr', 'stdout']
.map((field) => error?.[field])
.filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
.map((value) => String(value).trim())
.join('\n') || (error instanceof Error ? error.message : String(error));
throw new Error(`failed to fetch hosted onboarding payload via ${host}: ${redactBearerTokens(detail)}`);
}
try {
return JSON.parse(output);
} catch (error) {
throw new Error(`hosted onboarding payload fetched via ${host} was not valid JSON: ${redactBearerTokens(error instanceof Error ? error.message : String(error))}`);
}
}
function sleep(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function pollOnboardingPayload({
async function pollOnboardingPayload({
cloudHost,
fetchImpl,
pollIntervalMs,
pollTimeoutMs,
rawToken,
@ -142,9 +216,16 @@ function pollOnboardingPayload({
while (Date.now() <= deadline) {
try {
return fetchOnboardingPayload({ rawToken, tenantBaseUrl });
return await fetchOnboardingPayload({ fetchImpl, rawToken, tenantBaseUrl });
} catch (error) {
lastError = error;
if (String(cloudHost ?? '').trim() !== '') {
try {
return fetchOnboardingPayloadViaCloudHost({ cloudHost, rawToken, tenantBaseUrl });
} catch (remoteError) {
lastError = remoteError;
}
}
if (Date.now() >= deadline) {
break;
}
@ -155,7 +236,7 @@ function pollOnboardingPayload({
throw new Error(`timed out waiting for hosted onboarding payload from ${tenantBaseUrl}: ${String(lastError instanceof Error ? lastError.message : lastError)}`);
}
function main() {
async function main() {
const args = parseArgs(process.argv.slice(2));
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-hosted-mobile-onboarding-'));
@ -173,7 +254,8 @@ function main() {
restartHostedTenantRuntime(args.cloudHost, args.tenantId);
const tenantBaseUrl = deriveTenantBaseUrl(args.controlPlaneUrl, args.tenantId);
const qrPayload = pollOnboardingPayload({
const qrPayload = await pollOnboardingPayload({
cloudHost: args.cloudHost,
pollIntervalMs: args.pollIntervalMs,
pollTimeoutMs: args.pollTimeoutMs,
rawToken,
@ -216,4 +298,9 @@ function main() {
}
}
main();
if (process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH) {
main().catch((error) => {
console.error(redactBearerTokens(error instanceof Error ? error.message : String(error)));
process.exit(1);
});
}

View file

@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
deriveTenantBaseUrl,
fetchOnboardingPayload,
fetchOnboardingPayloadViaCloudHost,
redactBearerTokens,
} from './bootstrap-hosted-mobile-onboarding.mjs';
test('deriveTenantBaseUrl maps the control-plane host to the tenant subdomain', () => {
assert.equal(
deriveTenantBaseUrl('https://cloud.pulserelay.pro/', 't-CANARY123'),
'https://t-canary123.cloud.pulserelay.pro',
);
});
test('fetchOnboardingPayload uses bearer auth without leaking it in failures', async () => {
const token = 'pulse_secret_token.value/with+chars';
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ options, url });
return {
ok: false,
status: 401,
text: async () => `bad Authorization: Bearer ${token}`,
};
};
await assert.rejects(
() => fetchOnboardingPayload({
fetchImpl,
rawToken: token,
tenantBaseUrl: 'https://t-canary.cloud.pulserelay.pro',
}),
(error) => {
assert.match(error.message, /Bearer \[REDACTED\]/);
assert.doesNotMatch(error.message, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
return true;
},
);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'https://t-canary.cloud.pulserelay.pro/api/onboarding/qr');
assert.equal(calls[0].options.headers.Authorization, `Bearer ${token}`);
});
test('redactBearerTokens redacts bearer values in arbitrary command text', () => {
assert.equal(
redactBearerTokens('Command failed: curl -H Authorization: Bearer abc.def/ghi'),
'Command failed: curl -H Authorization: Bearer [REDACTED]',
);
});
test('fetchOnboardingPayloadViaCloudHost passes the bearer token over stdin only', () => {
const token = 'pulse_secret_token.value/with+chars';
const calls = [];
const payload = fetchOnboardingPayloadViaCloudHost({
cloudHost: 'root@pulse-cloud',
rawToken: token,
tenantBaseUrl: 'https://t-canary.cloud.pulserelay.pro',
runner: (command, args, options) => {
calls.push({ args, command, options });
return JSON.stringify({
deep_link: 'pulse://pair',
instance_id: 'relay_123',
relay: { enabled: true, url: 'wss://relay.example/ws' },
});
},
});
assert.equal(payload.instance_id, 'relay_123');
assert.equal(calls.length, 1);
assert.equal(calls[0].command, 'ssh');
assert.equal(calls[0].options.input, token);
assert.doesNotMatch(calls[0].args.join(' '), new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
});

View file

@ -5,6 +5,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
import {
assertHostedTenantRuntimeExists,
@ -22,12 +23,12 @@ function usage(message) {
}
console.error(
'usage: node ./tests/integration/scripts/hosted-tenant-approval-store.mjs <create|get> --tenant-id <id> [--org-id <id>] [--cloud-host <host>] [approval options]',
'usage: node ./tests/integration/scripts/hosted-tenant-approval-store.mjs <create|get> --tenant-id <id> [--org-id <id>] [--cloud-host <host>] [--no-restart] [approval options]',
);
process.exit(1);
}
function parseArgs(argv) {
export function parseArgs(argv) {
if (argv.length === 0) {
usage('missing action');
}
@ -36,6 +37,7 @@ function parseArgs(argv) {
action: argv[0],
cloudHost: 'root@pulse-cloud',
orgId: '',
restartAfterCreate: true,
tenantId: '',
passthrough: [],
};
@ -55,6 +57,10 @@ function parseArgs(argv) {
parsed.orgId = argv[index + 1] ?? usage('missing value for --org-id');
index += 1;
break;
case '--no-restart':
case '--skip-restart':
parsed.restartAfterCreate = false;
break;
case '--help':
case '-h':
usage();
@ -131,12 +137,14 @@ function main() {
const output = runRemote(args.cloudHost, remoteArgs.join(' '));
const payload = JSON.parse(output);
if (args.action === 'create') {
if (args.action === 'create' && args.restartAfterCreate) {
// Approval store state is loaded in-memory at runtime startup. Hosted proof
// seeding edits the backing file out-of-band, so restart the tenant runtime
// before returning to ensure the live process serves the seeded approval.
restartHostedTenantRuntime(args.cloudHost, args.tenantId);
payload.runtimeRestarted = true;
} else if (args.action === 'create') {
payload.runtimeRestarted = false;
}
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
@ -148,9 +156,14 @@ function main() {
}
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
const invokedAsScript = process.argv[1]
&& import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedAsScript) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}

View file

@ -0,0 +1,81 @@
#!/usr/bin/env node
import process from 'node:process';
import { pathToFileURL } from 'node:url';
import { restartHostedTenantRuntime } from './hosted-tenant-runtime.mjs';
const DEFAULT_CLOUD_HOST = 'root@pulse-cloud';
function usage(message) {
if (message) {
console.error(`error: ${message}`);
console.error('');
}
console.error(
'usage: node ./tests/integration/scripts/hosted-tenant-runtime-restart.mjs --tenant-id <id> [--cloud-host <user@host>]',
);
process.exit(1);
}
export function parseArgs(argv) {
const parsed = {
cloudHost: DEFAULT_CLOUD_HOST,
tenantId: '',
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case '--cloud-host':
parsed.cloudHost = argv[index + 1] ?? usage('missing value for --cloud-host');
index += 1;
break;
case '--tenant-id':
parsed.tenantId = argv[index + 1] ?? usage('missing value for --tenant-id');
index += 1;
break;
case '--help':
case '-h':
usage();
break;
default:
usage(`unsupported flag ${arg}`);
}
}
parsed.cloudHost = String(parsed.cloudHost || '').trim();
parsed.tenantId = String(parsed.tenantId || '').trim();
if (!parsed.tenantId) {
usage('--tenant-id is required');
}
if (!parsed.cloudHost) {
usage('--cloud-host is required');
}
return parsed;
}
export function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
restartHostedTenantRuntime(args.cloudHost, args.tenantId);
process.stdout.write(`${JSON.stringify({
cloudHost: args.cloudHost,
restarted: true,
tenantId: args.tenantId,
}, null, 2)}\n`);
}
const invokedAsScript = process.argv[1]
&& import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedAsScript) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}

View file

@ -7,7 +7,9 @@ import {
hostedTenantRuntimeExistsScript,
restartHostedTenantRuntime,
} from './hosted-tenant-runtime.mjs';
import { parseArgs as parseApprovalStoreArgs } from './hosted-tenant-approval-store.mjs';
import { parseArgs as parseRuntimeCheckArgs } from './hosted-tenant-runtime-check.mjs';
import { parseArgs as parseRuntimeRestartArgs } from './hosted-tenant-runtime-restart.mjs';
test('hostedTenantContainerName derives the canonical runtime container name', () => {
assert.equal(hostedTenantContainerName(' t-P62TP8K28Y '), 'pulse-t-P62TP8K28Y');
@ -77,3 +79,37 @@ test('hosted tenant runtime check CLI defaults to the production cloud host', ()
assert.equal(parsed.cloudHost, 'root@pulse-cloud');
assert.equal(parsed.tenantId, 't-canary');
});
test('hosted tenant runtime restart CLI defaults to the production cloud host', () => {
const parsed = parseRuntimeRestartArgs(['--tenant-id', 't-canary']);
assert.equal(parsed.cloudHost, 'root@pulse-cloud');
assert.equal(parsed.tenantId, 't-canary');
});
test('hosted approval seeding keeps runtime restart enabled by default', () => {
const parsed = parseApprovalStoreArgs([
'create',
'--tenant-id',
't-canary',
'--approval-id',
'approval-123',
]);
assert.equal(parsed.restartAfterCreate, true);
assert.deepEqual(parsed.passthrough, ['--approval-id', 'approval-123']);
});
test('hosted approval seeding can defer runtime restart for transactional proof setup', () => {
const parsed = parseApprovalStoreArgs([
'create',
'--tenant-id',
't-canary',
'--no-restart',
'--approval-id',
'approval-123',
]);
assert.equal(parsed.restartAfterCreate, false);
assert.deepEqual(parsed.passthrough, ['--approval-id', 'approval-123']);
});