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.
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.
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
}
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",
]
}
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.
frontend.tfResources, in deployment order:
| Resource | Name pattern | Purpose |
|---|---|---|
aws_s3_bucket.frontend | ai-sandbox-console-frontend-959228203854 | Holds index.html, app.js, styles.css, favicon, logo, config.json, sdk/v1/** |
aws_s3_bucket_versioning | — | Enabled (rollback safety) |
aws_s3_bucket_server_side_encryption_configuration | — | KMS encryption with ceo-office-low-risk CMK |
aws_s3_bucket_public_access_block | — | All four blocks ON |
aws_cloudfront_origin_access_control.frontend | ai-sandbox-console-oac | S3 OAC, always sign, sigv4 |
aws_cloudfront_origin_access_control.lambda_backend | ai-sandbox-console-lambda-oac | Lambda OAC, always sign, sigv4 |
aws_cloudfront_function.spa_router | ai-sandbox-console-spa-router | Viewer-request function attached to default cache behavior |
aws_s3_object.frontend_assets (for_each) | 6 files | index.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 files | sdk/v1/css/tokens.css, sdk/v1/js/cartridge-auth.js |
aws_s3_object.runtime_config | config.json | Cognito IDs rendered at deploy via jsonencode(local.runtime_config) |
aws_cloudfront_distribution.frontend | — | Distribution ID E2P3ZCWVM3UAQH, domain da7cclwu7mps0.cloudfront.net |
aws_s3_bucket_policy.frontend | — | Allow CloudFront OAC s3:GetObject + Deny insecure transport |
Two cache behaviors, in priority order:
/api/* → Lambda Function URL via OAC. Cache policy: CachingDisabled (managed AWS ID 4135ea2d-…). Origin request policy: AllViewerExceptHostHeader. Methods: GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE.CachingOptimized (managed AWS ID 658327ea-…). Response headers: SecurityHeadersPolicy (managed AWS ID 67f7725c-…). Function association: SPA router on viewer-request.
Geographic restriction: whitelist US, CA. Price class: PriceClass_100
(US/CA/EU edge locations only — cheapest tier).
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.
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).
backend.tfResources:
| Resource | Name | Notes |
|---|---|---|
aws_cloudwatch_log_group.backend | /aws/lambda/ai-sandbox-console-backend | 90-day retention, encrypted with eng-low-risk CMK |
aws_iam_role.backend | ai-sandbox-console-backend | Permission boundary: ${var.permission_boundary_arn} (bootstrap-issued) |
aws_iam_role_policy.backend | ai-sandbox-console-backend | 4 statements: logs, DiscoverCognitoClient, CacheTable, KMSDecryptBackendKey |
data.archive_file.backend_zip | — | Zips lambda/ dir → build/console-backend.zip |
aws_lambda_function.backend | ai-sandbox-console-backend | Node 20, 512MB, 15s timeout, ESM handler |
aws_lambda_function_url.backend | — | authorization_type = "AWS_IAM" |
aws_lambda_permission.cf_invoke_url | — | CloudFront → lambda:InvokeFunctionUrl |
aws_lambda_permission.cf_invoke | — | CloudFront → lambda:InvokeFunction (BOTH required for OAC, AWS docs miss this) |
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.
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.
console/lambda/handler.js.
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.
| Method + path | Auth | Response 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 } |
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.
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)
}
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: [],
},
];
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.
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"
}
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();
}
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.
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).
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);
}
}
<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.
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().
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).
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);
});
});
}
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;
}
Two messages, in this exact order:
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.
sdk/v1/js/cartridge-auth.js is a small IIFE that:
window.parent !== window). If not, rejects the ready promise so the cartridge can fall back to its own login modal.{ type: 'cartridge-auth', ... }, the ready promise resolves with the token bundle.{ type: 'cartridge-ready' } to parent.
Exposed as window.PCSDK.auth.ready(). Cartridges call:
PCSDK.auth.ready().then(({ accessToken, idToken, email }) => {
myApiClient.setToken(accessToken);
bootApp(email);
});
/sdk/v1/*/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.
/sdk/v1/js/cartridge-auth.jsThe 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
styles.css.api() function. Could become PCSDK.api.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.
Two layers:
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.
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.
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.
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.
Reference for direct AWS CLI use. AI-Sandbox account: 959228203854,
region us-east-1.
| Resource type | Name / ID |
|---|---|
| CloudFront distribution | E2P3ZCWVM3UAQH |
| CloudFront default domain | da7cclwu7mps0.cloudfront.net |
| S3 bucket (frontend) | ai-sandbox-console-frontend-959228203854 |
| Lambda function | ai-sandbox-console-backend |
| Lambda Function URL auth | AWS_IAM |
| IAM role | ai-sandbox-console-backend |
| CloudWatch log group | /aws/lambda/ai-sandbox-console-backend |
| CloudFront function | ai-sandbox-console-spa-router |
| CloudFront S3 OAC | ai-sandbox-console-oac |
| CloudFront Lambda OAC | ai-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 domain | ai-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} |
# 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
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.