PayCargo · AI-Sandbox · Step 6.h

6.hConsole v0 — implementation reference

Exact how-it-works. Files, lines, data shapes, request flows. If you needed to rebuild the Console from scratch, this is the doc.
Companion to: Step.6.g (build walkthrough) · Status: Reflects live AI-Sandbox deployment as of 2026-05-30
Reference
Step 6.g is the narrative: what shipped, why, what it costs. This doc is the wiring diagram: every file in console/, every Cognito API call, every CloudFront origin, every Lambda endpoint, every postMessage. Read alongside the source — every callout in here points at a specific file path.
Doctrine update · 2026-06 · cartridge backends only

The Console itself stays on the OAC + Lambda Function URL pattern documented below — its backend calls are GET-only and OAC handles GET cleanly. Cartridges whose backends accept POST/PUT/PATCH cannot use this pattern. CloudFront OAC + Function URL has a documented body-hash mismatch on non-GET methods (SigV4 fails with "signature does not match" on every POST). The chatbot cartridge surfaced this and switched to a different shape:

  • CloudFront /api/* origin → API Gateway HTTP API (not a Function URL).
  • Layer 2 enforcement → an X-Origin-Secret shared header injected via the CloudFront origin's custom_header block, verified in the Lambda handler. Direct hits on the API Gateway endpoint return 403.
  • The secret is a Terraform random_password (64 char alphanumeric) threaded into both sides at apply time.
  • Cost delta: roughly $1 per million requests for API Gateway HTTP API. Trivial at internal scale.

Reference implementation: cartridges/global/chatbot/. See Step 4.5.b's doctrine-update callout for the full rationale and the three-layer doctrine impact (Layers 1 and 3 unchanged; Layer 2 has two variants now, picked by HTTP method shape).

Contents

  1. Directory layout
  2. Deployment topology
  3. Cognito wiring (shared pool, new client)
  4. CloudFront + S3 (frontend.tf walkthrough)
  5. SPA router (CloudFront Function)
  6. Lambda + Function URL (backend.tf walkthrough)
  7. Backend handler — endpoints + JWT verification
  8. The CARTRIDGES manifest
  9. Runtime config.json shape
  10. Frontend bootstrap sequence
  11. Login modal — 4 steps, exact Cognito calls
  12. Shell materialization (bypass-proof gating)
  13. Cartridge launch — tile click to running app
  14. postMessage handshake — JWT injection into iframe
  15. SDK v1 — tokens.css + cartridge-auth.js
  16. Permission gating (cognito:groups → tile visibility)
  17. Composition wiring (ai-sandbox/main.tf)
  18. CI workflow integration
  19. Identity table — every resource by ARN / ID

1. Directory layout

Every file under console/:

console/
├── versions.tf                    Terraform ≥ 1.9, aws ≥ 5.0
├── variables.tf                   11 input vars (project, account, region,
│                                  perm boundary, frontend+backend KMS, agent
│                                  state DDB, 3 Cognito values from obs-app,
│                                  optional custom domain + ACM cert)
├── auth.tf                        ONE aws_cognito_user_pool_client on the
│                                  obs-app pool. No new pool.
├── frontend.tf                    S3 + CloudFront + OAC + SPA router fn +
│                                  asset uploads + runtime config + bucket
│                                  policy
├── backend.tf                     Lambda + Function URL + IAM role/policy +
│                                  CW log group + cloudfront permissions
├── outputs.tf                     6 outputs (URL, distribution ID, client ID,
│                                  SDK base URL, bucket name, fn name)
│
├── cloudfront/
│   └── spa-router.js              CloudFront Function (cloudfront-js-2.0).
│                                  Allowlist of static assets + /sdk/* prefix
│                                  passthrough + fallback rewrite to
│                                  /index.html.
│
├── frontend/
│   ├── index.html                 App shell template + splash + login modal
│   ├── app.js                     ~600 LOC. Bootstrap, Cognito SRP+TOTP,
│   │                              cartridge launcher, iframe postMessage.
│   ├── styles.css                 PayCargo tokens inlined + console-shell
│   │                              grid + cartridge tile + login modal.
│   ├── favicon.svg
│   └── paycargo-logo-white.svg
│
├── lambda/
│   ├── handler.js                 Two endpoints: /api/config, /api/cartridges.
│   │                              JWT verification, hardcoded CARTRIDGES array.
│   ├── package.json               ESM ("type": "module"). Deps: aws-jwt-verify,
│   │                              @aws-sdk/client-cognito-identity-provider.
│   └── node_modules/              gitignored; installed at deploy by CI.
│
└── sdk/
    └── v1/
        ├── css/
        │   └── tokens.css         The :root design tokens. Cartridges
        │                          <link> this.
        └── js/
            └── cartridge-auth.js  PCSDK.auth.ready() promise. Cartridges
                                   call to receive accessToken via postMessage.

2. Deployment topology

┌──────────────────────────────────────────────────────────────────────────────┐ │ AI-Sandbox AWS Account │ │ (959228203854) │ │ │ │ ┌─────────────────────────┐ ┌─────────────────────────┐ │ │ │ Cognito User Pool │ │ DynamoDB cache table │ │ │ │ us-east-1_fJLnhjqnl │ │ (agent state) │ │ │ │ (owned by obs-app) │ │ (shared) │ │ │ │ │ └─────────────────────────┘ │ │ │ ┌────────────────────┐ │ │ │ │ │ Client: obs-app │ │ ┌──────────────────────────────┐ │ │ │ └────────────────────┘ │ │ KMS CMKs (data gov grid) │ │ │ │ ┌────────────────────┐ │ │ - ceo-office-low-risk │ │ │ │ │ Client: CONSOLE │ │ │ - eng-low-risk │ │ │ │ │ (registered tonight)│ │ │ (shared with obs-app) │ │ │ │ └────────────────────┘ │ └──────────────────────────────┘ │ │ └─────────────────────────┘ │ │ │ │ ┌────────────────────────────────┐ ┌────────────────────────────────┐ │ │ │ OBS-APP (existing) │ │ CONSOLE (new tonight) │ │ │ │ d2kh3b2wl3msbg.cloudfront.net │ │ da7cclwu7mps0.cloudfront.net │ │ │ │ │ │ │ │ │ │ ┌────────────┐ ┌────────────┐│ │ ┌────────────┐ ┌────────────┐│ │ │ │ │ S3 bucket │ │ Lambda ││ │ │ S3 bucket │ │ Lambda ││ │ │ │ │ obs-app │ │ obs ││ │ │ console │ │ console ││ │ │ │ │ frontend │ │ backend ││ │ │ frontend │ │ backend ││ │ │ │ └────────────┘ └────────────┘│ │ └────────────┘ └────────────┘│ │ │ │ ▲ ▲ │ │ ▲ ▲ │ │ │ │ └──OAC───┐ ┌─OAC─┘ │ │ └──OAC───┐ ┌─OAC─┘ │ │ │ │ CloudFront │ │ CloudFront │ │ │ │ (default: S3) │ │ (default: S3) │ │ │ │ (/api/*: Lambda) │ │ (/api/*: Lambda) │ │ │ └────────────────────────────────┘ └────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────────────┘

Two completely independent CloudFront distributions, S3 buckets, Lambdas. Shared at the Cognito + KMS + DDB layer. The JWT issued by signing in to Console's app client is valid against any cartridge (current or future) that whitelists the same Cognito issuer — that's the SSO mechanic.

3. Cognito wiring

3.1 The shared pool

The pool is us-east-1_fJLnhjqnl, owned by the obs-app Terraform module (cartridges/administrative/obs/auth.tf). Configuration that matters:

mfa_configuration         = "ON"            # TOTP required, no SMS
alias_attributes          = ["email"]       # email is the sign-in alias
auto_verified_attributes  = ["email"]
explicit_auth_flows       = [
  "ALLOW_USER_SRP_AUTH",                    # SRP for the custom modal
  "ALLOW_REFRESH_TOKEN_AUTH",
]
user_pool_add_ons {
  advanced_security_mode = "ENFORCED"       # fingerprint script required for speed
}

3.2 Console's app client

console/auth.tf creates ONE aws_cognito_user_pool_client named ai-sandbox-console-client on the shared pool. Client ID: o98i3tkmf8gi7e0qavli7edhh.

resource "aws_cognito_user_pool_client" "console" {
  name         = "${var.project_name}-console-client"
  user_pool_id = var.cognito_user_pool_id        # <-- from obs-app output

  generate_secret = false                          # SPA, no client secret

  allowed_oauth_flows                  = ["code"]
  allowed_oauth_scopes                 = ["email", "openid", "profile"]
  allowed_oauth_flows_user_pool_client = true

  callback_urls = compact([
    "https://${aws_cloudfront_distribution.frontend.domain_name}/callback",
    var.custom_domain != "" ? "https://${var.custom_domain}/callback" : null,
  ])

  logout_urls = compact([
    "https://${aws_cloudfront_distribution.frontend.domain_name}/",
    var.custom_domain != "" ? "https://${var.custom_domain}/" : null,
  ])

  access_token_validity  = 1      # hours
  id_token_validity      = 1      # hours
  refresh_token_validity = 30     # days
  token_validity_units {
    access_token  = "hours"
    id_token      = "hours"
    refresh_token = "days"
  }

  prevent_user_existence_errors = "ENABLED"
  supported_identity_providers  = ["COGNITO"]

  explicit_auth_flows = [
    "ALLOW_USER_SRP_AUTH",
    "ALLOW_REFRESH_TOKEN_AUTH",
  ]
}

3.3 Why this works as SSO

Cognito's JWT verifier checks iss (issuer, the pool URL) and client_id (the app client this JWT was issued to). When a future cartridge wants to accept Console-issued JWTs:

const verifier = CognitoJwtVerifier.create({
  userPoolId: 'us-east-1_fJLnhjqnl',       // same pool
  tokenUse:   'access',
  clientId:   'o98i3tkmf8gi7e0qavli7edhh', // Console's client ID
});

One pool, multiple clients per app. A user signed in once via Console gets a JWT that every cartridge verifies against the same pool with the same client ID it was minted for. No federation, no token exchange, no re-auth.

4. CloudFront + S3 — frontend.tf

Resources, in deployment order:

ResourceName patternPurpose
aws_s3_bucket.frontendai-sandbox-console-frontend-959228203854Holds index.html, app.js, styles.css, favicon, logo, config.json, sdk/v1/**
aws_s3_bucket_versioningEnabled (rollback safety)
aws_s3_bucket_server_side_encryption_configurationKMS encryption with ceo-office-low-risk CMK
aws_s3_bucket_public_access_blockAll four blocks ON
aws_cloudfront_origin_access_control.frontendai-sandbox-console-oacS3 OAC, always sign, sigv4
aws_cloudfront_origin_access_control.lambda_backendai-sandbox-console-lambda-oacLambda OAC, always sign, sigv4
aws_cloudfront_function.spa_routerai-sandbox-console-spa-routerViewer-request function attached to default cache behavior
aws_s3_object.frontend_assets (for_each)6 filesindex.html, app.js, styles.css, favicon.svg, paycargo-logo-white.svg, plus runtime config.json (separate resource)
aws_s3_object.sdk_v1 (for_each)2 filessdk/v1/css/tokens.css, sdk/v1/js/cartridge-auth.js
aws_s3_object.runtime_configconfig.jsonCognito IDs rendered at deploy via jsonencode(local.runtime_config)
aws_cloudfront_distribution.frontendDistribution ID E2P3ZCWVM3UAQH, domain da7cclwu7mps0.cloudfront.net
aws_s3_bucket_policy.frontendAllow CloudFront OAC s3:GetObject + Deny insecure transport

4.1 Distribution behaviors

Two cache behaviors, in priority order:

Geographic restriction: whitelist US, CA. Price class: PriceClass_100 (US/CA/EU edge locations only — cheapest tier).

4.2 Runtime config rendering

frontend.tf defines a local that becomes /config.json:

locals {
  runtime_config = {
    region            = var.region
    accountId         = var.account_id
    cognitoUserPoolId = var.cognito_user_pool_id
    cognitoClientId   = aws_cognito_user_pool_client.console.id
    cognitoDomain     = "${var.cognito_user_pool_domain}.auth.${var.region}.amazoncognito.com"
    redirectUri       = "https://${aws_cloudfront_distribution.frontend.domain_name}/callback"
    logoutUri         = "https://${aws_cloudfront_distribution.frontend.domain_name}/"
    apiBase           = "/api"
    sdkBase           = "/sdk/v1"
    deployedAt        = timestamp()
  }
}
resource "aws_s3_object" "runtime_config" {
  bucket       = aws_s3_bucket.frontend.id
  key          = "config.json"
  content      = jsonencode(local.runtime_config)
  content_type = "application/json"
  kms_key_id   = var.frontend_kms_key_arn
}

The timestamp() in deployedAt guarantees the content changes every apply, so the S3 object is always re-uploaded and CloudFront sees a new ETag (no manual invalidation needed for config.json). The other 8 S3 objects use source_hash = filemd5(...) so they only update when the source file changes.

5. SPA router (CloudFront Function)

console/cloudfront/spa-router.js. Runs at the edge on every viewer request that hits the default cache behavior (not the /api/* behavior, which bypasses the function). Source in full:

function handler(event) {
  var request = event.request;
  var uri = request.uri;

  // Allowlisted static assets — fetched from S3 unchanged.
  var staticAssets = [
    '/', '/index.html', '/app.js', '/styles.css',
    '/config.json', '/favicon.ico', '/favicon.svg',
    '/paycargo-logo-white.svg',
  ];
  if (staticAssets.indexOf(uri) !== -1) {
    return request;
  }

  // SDK v1 assets under /sdk/* are real S3 objects (tokens.css, etc.).
  // Cartridges import these from the Console origin.
  if (uri.indexOf('/sdk/') === 0) {
    return request;
  }

  // Anything else is a client-side route — serve index.html. The SPA
  // reads the original path (and query string, which CloudFront preserves
  // automatically) and renders accordingly.
  request.uri = '/index.html';
  return request;
}

Critical: this function is attached only to the default cache behavior. The /api/* ordered behavior bypasses it entirely. If we attached it to /api/*, a Lambda 401 would get rewritten to /index.html and the SPA would try to JSON-parse HTML. We learned that in obs-app (Step 4.5).

6. Lambda + Function URL — backend.tf

Resources:

ResourceNameNotes
aws_cloudwatch_log_group.backend/aws/lambda/ai-sandbox-console-backend90-day retention, encrypted with eng-low-risk CMK
aws_iam_role.backendai-sandbox-console-backendPermission boundary: ${var.permission_boundary_arn} (bootstrap-issued)
aws_iam_role_policy.backendai-sandbox-console-backend4 statements: logs, DiscoverCognitoClient, CacheTable, KMSDecryptBackendKey
data.archive_file.backend_zipZips lambda/ dir → build/console-backend.zip
aws_lambda_function.backendai-sandbox-console-backendNode 20, 512MB, 15s timeout, ESM handler
aws_lambda_function_url.backendauthorization_type = "AWS_IAM"
aws_lambda_permission.cf_invoke_urlCloudFront → lambda:InvokeFunctionUrl
aws_lambda_permission.cf_invokeCloudFront → lambda:InvokeFunction (BOTH required for OAC, AWS docs miss this)

6.1 IAM policy detail

statement {
  sid     = "Logs"
  actions = ["logs:CreateLogStream", "logs:PutLogEvents"]
  resources = ["${aws_cloudwatch_log_group.backend.arn}:*"]
}
statement {
  sid     = "DiscoverCognitoClient"
  actions = [
    "cognito-idp:ListUserPoolClients",
    "cognito-idp:DescribeUserPoolClient",
  ]
  resources = [var.cognito_user_pool_arn]    # scoped to the obs-app pool only
}
statement {
  sid       = "CacheTable"
  actions   = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem"]
  resources = [var.agent_state_table_arn]
}
statement {
  sid       = "KMSDecryptBackendKey"
  actions   = ["kms:Decrypt", "kms:DescribeKey"]
  resources = [var.backend_kms_key_arn]
}

Minimum-permissions design. No Bedrock, no CloudWatch metrics, no Cost Explorer — none of which Console needs. If a future endpoint needs more, this is where it gets added.

6.2 Lambda environment

environment {
  variables = {
    REGION               = var.region                    # us-east-1
    ACCOUNT_ID           = var.account_id                # 959228203854
    COGNITO_USER_POOL_ID = var.cognito_user_pool_id     # us-east-1_fJLnhjqnl
    CACHE_TABLE          = var.agent_state_table_name
    CACHE_SESSION_ID     = "console"                     # partition key prefix
  }
}

COGNITO_CLIENT_ID is intentionally NOT set as an env var to avoid the circular dependency (env var → client → CloudFront → Lambda URL → Lambda). The Lambda discovers it at cold start via ListUserPoolClients.

7. Backend handler — endpoints + JWT verification

console/lambda/handler.js.

7.1 Lazy Cognito client discovery

let jwtVerifier = null;
let cognitoClientId = null;

async function getJwtVerifier() {
  if (jwtVerifier) return jwtVerifier;
  if (!cognitoClientId) {
    const res = await cognitoIdp.send(new ListUserPoolClientsCommand({
      UserPoolId: process.env.COGNITO_USER_POOL_ID,
      MaxResults: 10,
    }));
    // Find the Console's client (by naming convention)
    const consoleClient = res.UserPoolClients.find(c =>
      c.ClientName?.endsWith('-console-client')
    );
    if (!consoleClient) throw new Error('no -console-client found on pool');
    cognitoClientId = consoleClient.ClientId;
  }
  jwtVerifier = CognitoJwtVerifier.create({
    userPoolId: process.env.COGNITO_USER_POOL_ID,
    tokenUse:   'access',
    clientId:   cognitoClientId,
  });
  return jwtVerifier;
}

Warm Lambdas reuse jwtVerifier and cognitoClientId across invocations. Cold start adds ~300ms for the ListUserPoolClients call; subsequent calls are instant.

7.2 Endpoint table

Method + pathAuthResponse shape
GET /api/config Public { region, accountId, cognitoUserPoolId }
GET /api/cartridges JWT required (X-Auth-Token: Bearer <access_token>) { cartridges: [...], user: { email, groups }, asOf }
any other 404 { error: "Not found", path }

7.3 JWT extraction

const authHeader =
  event.headers?.['x-auth-token']   ||
  event.headers?.['X-Auth-Token']   ||
  event.headers?.authorization      ||
  event.headers?.Authorization      ||
  '';
const token = authHeader.replace(/^Bearer\s+/i, '');

if (!token) return jsonResponse(401, { error: 'Missing X-Auth-Token header' });

let jwtPayload;
try {
  const verifier = await getJwtVerifier();
  jwtPayload = await verifier.verify(token);
} catch (err) {
  return jsonResponse(401, { error: 'Invalid or expired token' });
}

X-Auth-Token not Authorization: CloudFront's OAC owns Authorization for SigV4 signing to the Lambda Function URL. They'd conflict. Same convention as obs-app (documented in Step 4.5.a).

aws-jwt-verify verifies the JWT against the Cognito pool's JWKS, checks the issuer + audience (client ID) + expiry + signature. Returns the decoded payload on success, throws on any check failure.

8. The CARTRIDGES manifest

Hardcoded in handler.js for v0. Each entry's shape:

{
  id:             string,    // stable identifier
  name:           string,    // display name in the tile
  description:    string,    // one-line subtitle in the tile
  icon:           string,    // Font Awesome solid icon class (e.g. 'fa-chart-line')
  color:          string,    // tile accent: 'blue' | 'purple' | 'green' | 'orange'
  url:            string,    // where the cartridge lives (CloudFront URL)
  loadMode:      'iframe' | 'external' | 'comingsoon',
  requiredGroups: string[]   // cognito:groups required to see this cartridge
                             // (empty array = visible to everyone signed in)
}

8.1 Current contents

const CARTRIDGES = [
  {
    id: 'observability',
    name: 'Observability',
    description: 'Platform health · executive narrative · audit trail',
    icon: 'fa-chart-line',
    color: 'blue',
    url: 'https://d2kh3b2wl3msbg.cloudfront.net/',
    loadMode: 'external',          // opens in new tab — Obs is standalone
    requiredGroups: [],
  },
  {
    id: 'chatbot',
    name: 'Chatbot',
    description: 'Claude via Bedrock · first native cartridge',
    icon: 'fa-comments',
    color: 'purple',
    url: '',
    loadMode: 'comingsoon',        // disabled tile
    requiredGroups: [],
  },
  {
    id: 'sales-reporting',
    name: 'Sales Reporting',
    description: 'AI-generated revenue + pipeline summaries',
    icon: 'fa-chart-pie',
    color: 'green',
    url: '',
    loadMode: 'comingsoon',
    requiredGroups: [],
  },
];

8.2 Permission filter

function cartridgesForUser(jwtPayload) {
  const userGroups = jwtPayload['cognito:groups'] || [];
  const visible = CARTRIDGES.filter(c => {
    if (!c.requiredGroups || c.requiredGroups.length === 0) return true;
    return c.requiredGroups.every(g => userGroups.includes(g));
  });
  return {
    cartridges: visible,
    user: {
      email:  jwtPayload.email,
      groups: userGroups,
    },
    asOf: new Date().toISOString(),
  };
}

Standard subset check: user's groups must contain every entry in requiredGroups. Empty requiredGroups defaults to visible-to-all. Phase 2 will lock more cartridges behind specific group membership; today everything is open.

9. Runtime config.json shape

Rendered at deploy by Terraform, served as a public static asset at https://da7cclwu7mps0.cloudfront.net/config.json. SPA reads it once at bootstrap to know which Cognito pool/client to talk to. These IDs are public by design (client IDs are not secrets).

{
  "region":            "us-east-1",
  "accountId":         "959228203854",
  "cognitoUserPoolId": "us-east-1_fJLnhjqnl",
  "cognitoClientId":   "o98i3tkmf8gi7e0qavli7edhh",
  "cognitoDomain":     "ai-sandbox-obs-app-95922820.auth.us-east-1.amazoncognito.com",
  "redirectUri":       "https://da7cclwu7mps0.cloudfront.net/callback",
  "logoutUri":         "https://da7cclwu7mps0.cloudfront.net/",
  "apiBase":           "/api",
  "sdkBase":           "/sdk/v1",
  "deployedAt":        "2026-05-31T00:35:06Z"
}

10. Frontend bootstrap sequence

frontend/app.js, top-level IIFE:

(async () => {
  try {
    setSplashMsg('loading config...');
    CONFIG = await fetchConfig();                 // GET /config.json

    // PKCE callback path (only fires if user clicked SSO fallback)
    const url = new URL(window.location.href);
    const code = url.searchParams.get('code');
    if (code) {
      setSplashMsg('exchanging code for token...');
      await exchangeCodeForTokens(code);          // Cognito /oauth2/token
      window.history.replaceState({}, '', '/');
    }

    if (!hasValidToken()) {
      setupLoginModal();
      showLoginModal();
      return;
    }
    await bootContinue();
  } catch (err) {
    setSplashMsg('bootstrap failed: ' + err.message);
  }
})();

async function bootContinue() {
  materializeAppShell();                          // template → DOM (one-time)
  setSplashMsg('loading cartridges...');
  await renderUserChrome();                       // email + avatar in topbar
  bindSignout();                                  // signout button
  await loadCartridges();                         // GET /api/cartridges
  hideSplash();
}

10.1 Token storage

const STORAGE_KEYS = {
  accessToken: 'console.access',      // sessionStorage
  idToken:     'console.id',          // sessionStorage
  expiresAt:   'console.expires',     // sessionStorage
  email:       'console.email',       // sessionStorage
};
const PKCE_KEY = 'console.pkce';      // localStorage (survives redirects)

function hasValidToken() {
  const token = sessionStorage.getItem(STORAGE_KEYS.accessToken);
  const expiresAt = Number(sessionStorage.getItem(STORAGE_KEYS.expiresAt) || 0);
  return token && Date.now() < expiresAt;
}

Tokens in sessionStorage: short-lived, scoped to the tab, gone on close. PKCE verifier in localStorage: survives the Cognito multi-redirect flow (sign-in → forced password change → TOTP enrollment → callback). The verifier is one-time-use and deleted immediately after the code exchange.

11. Login modal — 4 steps, exact Cognito calls

Four <form class="login-step"> elements, one per step. Chrome's multi-form autocomplete heuristic was the reason for this split (one form with multiple input clusters fires a console warning).

11.1 Step transitions

credentials (visible by default) │ │ submit { email, password } ▼ cognitoUser.authenticateUser(authDetails, callbacks) │ ├─► onSuccess → stashCognitoSession(session) → bootContinue() ├─► totpRequired → showLoginStep('totp') ├─► newPasswordRequired(attrs) → showLoginStep('newpw') ├─► mfaSetup → cognitoUser.associateSoftwareToken({ associateSecretCode }) │ → showLoginStep('mfasetup') └─► onFailure(err) → humanizeCognitoError(err) totp (after credentials) │ │ submit { code: '123456' } (auto-submits when 6th digit entered) ▼ cognitoUser.sendMFACode(code, callbacks, 'SOFTWARE_TOKEN_MFA') │ ├─► onSuccess → stashCognitoSession(session) → bootContinue() └─► onFailure → 'That 6-digit code is not correct.' newpw (only fires for FORCE_CHANGE_PASSWORD users) │ │ submit { newPassword } ▼ cognitoUser.completeNewPasswordChallenge(newPw, attrs, callbacks) │ ├─► onSuccess → stashCognitoSession(session) → bootContinue() ├─► totpRequired → showLoginStep('totp') (still need MFA) ├─► mfaSetup → showLoginStep('mfasetup') (still need enrollment) └─► onFailure → 'Password must be at least 14 characters.' mfasetup (first-time TOTP enrollment) │ │ display secret in <code> block (user scans into Authy/etc.) │ submit { verifyCode: '123456' } ▼ cognitoUser.verifySoftwareToken(code, 'Authenticator', callbacks) │ ├─► onSuccess(session) → cognitoUser.setUserMfaPreference(null, │ { PreferredMfa: true, Enabled: true }, callback) │ → stashCognitoSession(session) → bootContinue() └─► onFailure → 'That 6-digit code is not correct.'

11.2 The withAuthGuard wrapper

Every Cognito callsite goes through withAuthGuard(operationName, fn). Sets an idempotency lock (authInFlight) and a 30-second timeout. If the SDK never calls back (silent network failure, malformed response), the timeout fires:

function withAuthGuard(operationName, fn) {
  if (authInFlight) return;
  authInFlight = true;
  setLoginLoading(true);

  authTimeoutId = setTimeout(() => {
    if (!authInFlight) return;
    authInFlight = false;
    setLoginError('Sign-in timed out. Use the SSO fallback below.');
    setLoginLoading(false);
  }, 30_000);

  try { fn(); }
  catch (err) {
    clearAuthGuard();
    setLoginError('Sign-in failed: ' + err.message);
    setLoginLoading(false);
  }
}

11.3 The advanced-security script

<head> loads the Cognito advanced-security data collector from amazon-cognito-assets.us-east-1.amazoncognito.com/amazon-cognito-advanced-security-data.min.js. No crossorigin attribute (that endpoint doesn't return CORS headers). The pool is set with AdvancedSecurityDataCollectionFlag: true so the SDK ships browser fingerprint data with every auth call. Without this, Cognito runs the slow risk-analysis path and sign-in takes ~15-20s instead of ~300ms.

11.4 The SSO fallback

Bottom of the modal: "Use PayCargo SSO (Hosted UI) →". Clicking redirects to Cognito Hosted UI via PKCE OAuth code grant:

async function redirectToHostedUI() {
  const verifier  = generatePkceVerifier();                 // random 43 chars
  const challenge = await pkceChallenge(verifier);          // SHA256(verifier), base64url
  localStorage.setItem(PKCE_KEY, verifier);                 // survives redirect

  const params = new URLSearchParams({
    response_type:         'code',
    client_id:             CONFIG.cognitoClientId,
    redirect_uri:          CONFIG.redirectUri,              // /callback
    scope:                 'openid email profile',
    code_challenge:        challenge,
    code_challenge_method: 'S256',
  });
  window.location.href = `https://${CONFIG.cognitoDomain}/oauth2/authorize?${params}`;
}

After Hosted UI completes, the browser lands at /callback?code=XXX. Bootstrap's URL inspection picks up the code, calls exchangeCodeForTokens (POST to /oauth2/token with the verifier), stores tokens, removes the query string, continues to bootContinue().

12. Shell materialization (bypass-proof gating)

index.html ships with an empty placeholder div + an inert template:

<body class="pc-app pc-app-console">
  <div id="app" class="console-shell" hidden></div>

  <template id="app-template">
    <aside class="console-sidebar">...</aside>
    <header class="console-topbar">...</header>
    <main class="console-main">...</main>
  </template>

  <div id="splash" class="splash">...</div>
  <div id="login-overlay" class="login-overlay" hidden>...</div>
  ...
</body>

Browsers parse the template's content into a DocumentFragment but don't render it, activate scripts, include it in the document tree, or fetch its resources. Until bootContinue() runs, #app is empty. DOM tampering on the overlay (e.g. style.display = 'none') reveals nothing.

function materializeAppShell() {
  const app = document.getElementById('app');
  if (!app || app.childElementCount > 0) return;
  const tmpl = document.getElementById('app-template');
  if (!tmpl || !tmpl.content) return;
  app.appendChild(tmpl.content.cloneNode(true));
}

Idempotent: re-entry (token refresh that lands here twice) is a no-op. Once materialized, the app shell stays in DOM for the session.

The CSS for the overlay hidden state is also worth flagging: .login-overlay[hidden] { display: none; } with attribute-selector specificity beats the .login-overlay { display: flex } base rule. Without this, setting overlay.hidden = true in JS does nothing (the base rule wins).

13. Cartridge launch — tile click to running app

13.1 Rendering the tiles

async function loadCartridges() {
  const data = await api('/cartridges');         // GET /api/cartridges
  cartridges = data.cartridges || [];
  renderCartridgeList();
}

function renderCartridgeList() {
  const listEl = document.getElementById('cartridges-list');
  listEl.innerHTML = cartridges.map(c => {
    const isComingSoon = c.loadMode === 'comingsoon';
    const isExternal   = c.loadMode === 'external';
    const disabledCls  = isComingSoon ? ' is-disabled' : '';
    const badge = isComingSoon
      ? '<span class="cartridge-tile-badge is-coming-soon">Coming soon</span>'
      : isExternal
        ? '<span class="cartridge-tile-badge is-external">Opens in new tab</span>'
        : '<span class="cartridge-tile-badge is-live">Live</span>';
    return `<button class="cartridge-tile${disabledCls}"
                    data-cartridge-id="${c.id}">
              <span class="cartridge-tile-icon is-${c.color}">
                <i class="fa-solid ${c.icon}"></i>
              </span>
              <span class="cartridge-tile-body">
                <span class="cartridge-tile-name">${c.name}</span>
                <span class="cartridge-tile-desc">${c.description}</span>
                ${badge}
              </span>
            </button>`;
  }).join('');

  // Bind click on each tile
  listEl.querySelectorAll('.cartridge-tile').forEach(btn => {
    btn.addEventListener('click', () => {
      const id = btn.dataset.cartridgeId;
      const cartridge = cartridges.find(c => c.id === id);
      if (cartridge) launchCartridge(cartridge);
    });
  });
}

13.2 The launch dispatcher

function launchCartridge(cartridge) {
  if (cartridge.loadMode === 'comingsoon') {
    return;                                       // disabled, no-op
  }
  if (cartridge.loadMode === 'external') {
    window.open(cartridge.url, '_blank', 'noopener,noreferrer');
    return;                                       // Obs today
  }
  // iframe — the native cartridge model
  activeCartridgeId = cartridge.id;
  document.querySelectorAll('.cartridge-tile').forEach(t => {
    t.classList.toggle('is-active', t.dataset.cartridgeId === cartridge.id);
  });
  document.getElementById('topbar-title').textContent    = cartridge.name;
  document.getElementById('topbar-subtitle').textContent = cartridge.description;
  document.getElementById('welcome-state').hidden        = true;
  const iframe = document.getElementById('cartridge-frame');
  iframe.hidden = false;
  iframe.src    = cartridge.url;
}

14. postMessage handshake — JWT injection into iframe

Two messages, in this exact order:

┌─────────────────────────────────┐ ┌─────────────────────────────────┐ │ Console (parent window) │ │ Cartridge (iframe) │ │ │ │ │ │ iframe.src = cartridge.url │ load │ HTML parses, scripts run. │ │ ────────────────────────────► │ │ cartridge-auth.js sends: │ │ │ │ │ │ │ ◄──── │ parent.postMessage( │ │ │ │ { type: 'cartridge-ready' }, │ │ │ │ '*'); │ │ │ │ │ │ message listener fires. │ │ │ │ Validates event.source === │ │ │ │ iframe.contentWindow. │ │ │ │ Responds with token bundle: │ │ │ │ │ ────► │ message listener fires. │ │ iframe.contentWindow. │ │ PCSDK.auth.ready() resolves │ │ postMessage({ │ │ with { accessToken, idToken, │ │ type: 'cartridge-auth', │ │ email, cartridgeId }. │ │ accessToken: '...', │ │ │ │ idToken: '...', │ │ Cartridge sets the token on │ │ email: '...', │ │ its API client and proceeds. │ │ cartridgeId: 'chatbot', │ │ │ │ }, '*'); │ │ │ └─────────────────────────────────┘ └─────────────────────────────────┘

14.1 Console-side listener

window.addEventListener('message', (event) => {
  if (!event.data || typeof event.data !== 'object') return;
  if (event.data.type !== 'cartridge-ready') return;
  const iframe = document.getElementById('cartridge-frame');
  if (!iframe || !iframe.contentWindow) return;
  if (event.source !== iframe.contentWindow) return;  // only respond to OUR iframe
  iframe.contentWindow.postMessage({
    type:         'cartridge-auth',
    accessToken:  getAccessToken(),
    idToken:      getIdToken(),
    email:        sessionStorage.getItem(STORAGE_KEYS.email),
    cartridgeId:  activeCartridgeId,
  }, '*');
});

Critical: event.source !== iframe.contentWindow check. Without it, ANY page in ANY tab could send { type: 'cartridge-ready' } to the Console window and trick it into responding with the JWT. The check confines the response to the iframe we just loaded ourselves.

14.2 Cartridge-side helper

sdk/v1/js/cartridge-auth.js is a small IIFE that:

  1. Checks if it's embedded (window.parent !== window). If not, rejects the ready promise so the cartridge can fall back to its own login modal.
  2. Registers a message listener; when parent sends { type: 'cartridge-auth', ... }, the ready promise resolves with the token bundle.
  3. Sends { type: 'cartridge-ready' } to parent.
  4. 4-second timeout — if parent never responds, ready promise rejects (cartridge can fall back).

Exposed as window.PCSDK.auth.ready(). Cartridges call:

PCSDK.auth.ready().then(({ accessToken, idToken, email }) => {
  myApiClient.setToken(accessToken);
  bootApp(email);
});

15. SDK v1 — what's at /sdk/v1/*

15.1 /sdk/v1/css/tokens.css

All :root custom properties. The single source of truth for the design system. Cartridges <link> this; updating a token here propagates to every cartridge on next deploy without a version bump (v1 is the unversioned facade, v2 would ship when a breaking change lands and old cartridges keep working on v1).

Contains: brand colors (--pc-blue, --pc-dark-blue, --pc-orange, etc.), neutrals, surface colors, sidebar colors, text colors, typography (--pc-font-family, font-size scale), border radius. Standard PayCargo token set.

15.2 /sdk/v1/js/cartridge-auth.js

The postMessage helper described in §14. Self-contained IIFE, ~70 LOC, no dependencies. Exposes:

window.PCSDK.auth.ready()       // Promise → { accessToken, idToken, email, cartridgeId }
window.PCSDK.auth.isEmbedded()  // boolean

15.3 What's NOT in the SDK yet

First-consumer doctrine (§21 of Step 6): the SDK gets shaped by what the first consuming app actually needs. Pre-extracting risks designing the wrong abstraction.

16. Permission gating

Two layers:

  1. Server-side filter in handler.js:cartridgesForUser(). Backend reads the JWT's cognito:groups claim, filters CARTRIDGES to those whose requiredGroups are a subset of the user's groups, returns only the visible list. The user never learns about cartridges they can't see.
  2. Frontend renders only what the API returned. No client-side filtering, no hardcoded "hide tile if". The Console is dumb about cartridge visibility — it trusts the API. This means visibility can change just by editing the Lambda's CARTRIDGES array; no frontend change required.

Caveat for v0: cartridge URLs are public CloudFront URLs. A user who learns about a hidden cartridge's URL (out of band) could navigate to it directly. Real authorization lives at the CARTRIDGE'S backend (its Lambda checks JWT before serving data). The Console's filter is discoverability + permission UX, not the cartridge's actual auth gate. Phase 2 will tighten this with cartridge-side group checks in each cartridge's Lambda.

17. Composition wiring

substrate/environments/ai-sandbox/main.tf. The new module block:

module "console_app" {
  source = "../../apps/console"

  project_name            = var.project_name
  account_id              = data.aws_caller_identity.current.account_id
  region                  = data.aws_region.current.name
  permission_boundary_arn = var.permission_boundary_arn

  frontend_kms_key_arn = module.identity.data_governance_kms_keys["ceo-office-low-risk"]
  backend_kms_key_arn  = module.identity.data_governance_kms_keys["eng-low-risk"]

  agent_state_table_arn  = module.data.agent_state_table_arn
  agent_state_table_name = module.data.agent_state_table_name

  # Wire to obs-app's existing pool — Console is a second client on the same pool
  cognito_user_pool_id     = module.observability_app.cognito_user_pool_id
  cognito_user_pool_arn    = module.observability_app.cognito_user_pool_arn
  cognito_user_pool_domain = module.observability_app.cognito_user_pool_domain

  tags = local.common_tags

  depends_on = [
    module.observability_app,
  ]
}

depends_on = [module.observability_app] ensures the pool ARN and domain outputs exist before Console tries to consume them. The three new outputs on obs-app (cognito_user_pool_arn, cognito_user_pool_domain, plus the existing cognito_user_pool_id) make this clean.

18. CI workflow integration

Both .github/workflows/terraform-plan.yml and terraform-apply.yml gained an "Install Lambda dependencies (console)" step before terraform init:

- name: Install Lambda dependencies (obs app)
  working-directory: cartridges/administrative/obs/lambda
  run: npm install --omit=dev --no-package-lock

- name: Install Lambda dependencies (console)
  working-directory: console/lambda
  run: npm install --omit=dev --no-package-lock

- name: Terraform init
  ...

Required because data.archive_file.backend_zip in Console's backend.tf reads lambda/ at plan time and needs node_modules present. Without this step, CI plans against an empty node_modules dir; the Lambda would deploy without dependencies and crash on first invocation.

19. Identity table — every resource by ARN / ID

Reference for direct AWS CLI use. AI-Sandbox account: 959228203854, region us-east-1.

Resource typeName / ID
CloudFront distributionE2P3ZCWVM3UAQH
CloudFront default domainda7cclwu7mps0.cloudfront.net
S3 bucket (frontend)ai-sandbox-console-frontend-959228203854
Lambda functionai-sandbox-console-backend
Lambda Function URL authAWS_IAM
IAM roleai-sandbox-console-backend
CloudWatch log group/aws/lambda/ai-sandbox-console-backend
CloudFront functionai-sandbox-console-spa-router
CloudFront S3 OACai-sandbox-console-oac
CloudFront Lambda OACai-sandbox-console-lambda-oac
Cognito user pool (shared, owned by obs-app)us-east-1_fJLnhjqnl
Cognito user pool client (Console)o98i3tkmf8gi7e0qavli7edhh
Cognito Hosted UI domainai-sandbox-obs-app-95922820.auth.us-east-1.amazoncognito.com
Frontend KMS CMK (data governance tier)ceo-office-low-risk
Backend KMS CMK (data governance tier)eng-low-risk
DDB cache table (shared)partition console on ${module.data.agent_state_table_name}

19.1 Useful one-liners

# List Console-related distributions
aws cloudfront list-distributions \
  --query "DistributionList.Items[?contains(Comment,'Console')]" --output table

# Invalidate the whole Console after a deploy
aws cloudfront create-invalidation \
  --distribution-id E2P3ZCWVM3UAQH --paths "/*"

# Tail Console Lambda logs in real time
aws logs tail /aws/lambda/ai-sandbox-console-backend --follow

# Inspect the Console Cognito client config
aws cognito-idp describe-user-pool-client \
  --user-pool-id us-east-1_fJLnhjqnl --client-id o98i3tkmf8gi7e0qavli7edhh

# Curl the cartridge inventory (after grabbing a JWT)
curl -H "X-Auth-Token: Bearer $JWT" \
  https://da7cclwu7mps0.cloudfront.net/api/cartridges

What this doc enables

Anyone reading this should be able to: reason about every Console request from edge to Lambda and back; add a new cartridge in <30 minutes; debug an auth failure by knowing which Cognito call to inspect; clone this pattern for a second platform console (say, a vendor-facing one in a different AWS account) and know exactly which 19 things have to be set up.