___ _ ___ _ _ ___ ___ ___ _ _ _ ___ ___ _____ ___
/ __| | / _ \| || | \ | \| __| | | \| |/ _ \| _ \_ _| __|
| (__| |_| (_) | || | |) | | |) | _|| |__ | .` | (_) | / | | | _|
\___|____\___/ \__/|___/ |___/|___|____| |_|\_|\___/|_|_\ |_| |___|
aws user group — new mexico, west texas, chihuahua, mx
Community website for the AWS User Group Cloud Del Norte (formerly awsaerospace).
live: https://clouddelnorte.org | dev: https://dev.clouddelnorte.org | meetup: https://www.meetup.com/awsugclouddelnorte/
- first end-to-end video call confirmed: sign-in → JWT exchange → jitsi room join with live video/audio
- first-user bootstrap complete — one moderator exists, room creation is live
- full auth chain live: Cognito Hosted UI →
/auth/callback/→ token exchange → jitsi iframe embed - operational detail, asset inventory, spin-up/down playbooks:
BryanChasko/jitsi-video-hosting-opsOPERATIONS.md
| Layer | Technology |
|---|---|
| Bundler | Vite 7 (multi-page app) |
| UI | React 19 + Cloudscape Design System 3.x |
| Language | TypeScript 5.9 |
| Tests | Vitest + @testing-library/react |
| Linter | ESLint 10 (flat config — eslint.config.js) |
| Build output | ./lib/ |
| Hosting | S3 + CloudFront |
npm install # install dependencies
npm run dev # dev server at localhost:8080
npm run build # tsc + vite build → ./lib/
npm run lint # eslint
npm test # vitest run
npm run test:watch # vitest (watch mode)
npm run test:ui # vitest --ui
npm run coverage # vitest run --coverageQuality gate before deploy: npm run lint && npm test && npm run build
This is a multi-page app (MPA) — each page is an independent Vite entry point. There is no React Router and no shared runtime bundle between pages.
Every page requires exactly three files:
src/pages/<name>/
index.html ← Vite HTML entry point
main.tsx ← mounts React root, imports global styles + tokens
app.tsx ← page component tree wrapped in Shell
| Page | Path | Notes |
|---|---|---|
| Feed (landing) | src/pages/feed/ |
default root redirect here |
| About | src/pages/home/ |
|
| Meetings | src/pages/meetings/ |
auth-gated |
| Create Meeting | src/pages/create-meeting/ |
moderator-only |
| Learning / API | src/pages/learning/api/ |
|
| Maintenance Calendar | src/pages/maintenance-calendar/ |
|
| Theme Preview | src/pages/theme/ |
- Create
src/pages/<name>/withindex.html,main.tsx, andapp.tsx - Register the entry in
vite.config.ts→build.rollupOptions.input - Add a nav item in
src/components/navigation/index.tsx
Every page must implement all of the following:
- Shell wrapper —
app.tsxwraps content in<Shell>fromsrc/layouts/shell - Theme state —
useState<Theme>initialized viainitializeTheme(), passed to Shell - Locale state —
useState<Locale>initialized viainitializeLocale(), passed to Shell inside<LocaleProvider> - Deep imports — all Cloudscape components imported via deep paths (e.g.
@cloudscape-design/components/button) -
t()translation — all user-visible strings uset('namespace.key')fromuseTranslation(), never hardcoded English -
document.title— set viat()so it updates on locale change -
data.tslocale-aware — if the page has adata.tsfile, metric labels / descriptions use translation keys (not raw strings)
import { useState } from 'react';
import { LocaleProvider } from '../../contexts/locale-context';
import Shell from '../../layouts/shell';
import Navigation from '../../components/navigation';
import Breadcrumbs from '../../components/breadcrumbs';
import { initializeTheme, applyTheme, setStoredTheme, type Theme } from '../../utils/theme';
import { initializeLocale, applyLocale, setStoredLocale, type Locale } from '../../utils/locale';
export default function App() {
const [theme, setTheme] = useState<Theme>(() => initializeTheme());
const [locale, setLocale] = useState<Locale>(() => initializeLocale());
const handleThemeChange = (newTheme: Theme) => {
setTheme(newTheme);
applyTheme(newTheme);
setStoredTheme(newTheme);
};
const handleLocaleChange = (newLocale: Locale) => {
setLocale(newLocale);
applyLocale(newLocale);
setStoredLocale(newLocale);
};
return (
<LocaleProvider locale={locale}>
<Shell
theme={theme}
onThemeChange={handleThemeChange}
locale={locale}
onLocaleChange={handleLocaleChange}
breadcrumbs={<Breadcrumbs active={{ text: 'Page Title', href: '/<name>/index.html' }} />}
navigation={<Navigation />}
>
{/* page content */}
</Shell>
</LocaleProvider>
);
}If a page has static data (e.g. metric labels, descriptions, topic names), those strings must be kept as translation keys and resolved at render time — not hardcoded as English strings:
// ❌ Wrong — hardcodes English, bypasses locale
export const metrics = [{ label: 'Active Members', value: 42 }];
// ✅ Correct — return keys, resolve with t() at render time
export const METRIC_KEYS = [{ labelKey: 'home.metrics.activeMembers', value: 42 }];
// In the component:
const { t } = useTranslation();
metrics.map(m => ({ ...m, label: t(m.labelKey) }));- Deep imports only —
import Button from '@cloudscape-design/components/button'(never barrel imports) - No path aliases — all imports use relative paths
- Cloudscape only — no other UI component libraries
- No backend — static site; data fetched at build time and bundled as JSON
See AGENTS.md for the full architectural conventions and constraints.
The site supports two locales, toggled via 🇺🇸↔🇲🇽 in the top navigation:
| Locale | Flag | Description |
|---|---|---|
us |
🇺🇸 | New Mexican English — El Paso Spanglish + local slang |
mx |
🇲🇽 | Chihuahua norteño Spanish — Ciudad Juárez dialect |
Translation files live in src/locales/:
en-US.json— English (source of truth)es-MX.json— Spanish (human-reviewed translations)
Components use the useTranslation() hook:
import { useTranslation } from '../../hooks/useTranslation';
export default function MyComponent() {
const { t } = useTranslation();
return <Header>{t('namespace.headerTitle')}</Header>;
}See LOCALIZATION.md for dialect guides, key naming conventions, and translation workflow.
.woodpecker/deploy.yml triggers on push or manual dispatch to main or dev.
auth chain: workload x509 cert → IAM RolesAnywhere → heraldstack-ci-deploy role (no long-lived IAM keys)
| branch | target | cloudfront distribution |
|---|---|---|
main |
s3://awsaerospace.org |
ECC3LP1BL2CZS |
dev |
s3://dev.clouddelnorte.org |
woodpecker secret |
pipeline steps: install → build → deploy (main) or deploy-dev (dev) → screenshot-prod / screenshot-dev
Browser/device testing infrastructure: aws-device-farm-infra (private)
post-deploy screenshot captures run automatically and are available at predictable URLs — see docs/ops/ci-screenshots.md for the URL pattern and capture matrix.
aws sso login --profile aerospaceug-admin --use-device-code
npm run lint && npm test && npm run build
aws s3 sync lib/ s3://awsaerospace.org --delete --profile aerospaceug-admin
aws cloudfront create-invalidation \
--distribution-id ECC3LP1BL2CZS \
--paths "/*" \
--profile aerospaceug-admin| Resource | Value |
|---|---|
| AWS CLI profile | aerospaceug-admin |
| S3 bucket (prod) | awsaerospace.org |
| S3 bucket (dev) | dev.clouddelnorte.org |
| CloudFront (prod) | ECC3LP1BL2CZS |
| prod URL | clouddelnorte.org |
| dev URL | dev.clouddelnorte.org |
the site uses a zero-dependency OIDC PKCE flow against Cognito Hosted UI — no third-party auth library
sequenceDiagram
participant B as browser
participant H as hosted UI<br>cloud-del-norte.auth.us-west-2.amazoncognito.com
participant C as /auth/callback
participant T as token-exchange API<br>rwmypxz9z6.execute-api.us-west-2.amazonaws.com
participant J as jitsi iframe<br>meet.clouddelnorte.org
B->>H: beginLogin() — redirect with code_challenge
H-->>C: redirect with ?code=
C->>H: exchange code for tokens (PKCE)
C-->>B: tokens stored in sessionStorage, redirect to returnTo
B->>T: fetchJitsiToken(idToken)
T-->>B: signed jitsi JWT
B->>J: JitsiMeetExternalAPI(roomName, jwt)
tokens live in sessionStorage — tab-scoped, cleared on tab close. no localStorage, no cookies
| page | gate |
|---|---|
| /meetings | <RequireAuth> — any authenticated user |
| /create-meeting | <RequireAuth requireGroup="moderator"> — moderator-only |
| all other pages | public |
unauthenticated visitors are redirected to Hosted UI; returnTo encodes the original pathname so they land back after sign-in. moderator group mismatch renders a Cloudscape Alert with a sign-out option instead of the page content
- user lands on /meetings, authenticated
jitsi-embed.tsxcallsfetchJitsiToken()fromsrc/lib/jitsi-token.tsjitsi-token.tsposts the CognitoidTokento the token-exchange API; on 401 it silently refreshes tokens and retries once; on 403 it throwsBannedUserError- the returned signed JWT is passed to
JitsiMeetExternalAPIfromhttps://meet.clouddelnorte.org/external_api.js(lazy-loaded) - jitsi domain:
meet.clouddelnorte.org— 4-container ECS stack, JWT-gated. seechasko-labs/jitsi-video-hostingfor the runtime
| file | role |
|---|---|
src/lib/auth.ts |
OIDC PKCE core: beginLogin, handleCallback, getIdToken, getAccessToken, refreshTokens, signOut, decodeToken |
src/lib/jitsi-token.ts |
fetches jitsi JWT from token-exchange API; module-scoped cache; handles 401 retry + typed BannedUserError |
src/contexts/auth-context.tsx |
AuthProvider — silent-refresh timer at 80% token lifetime; storage event listener for cross-tab sign-out |
src/hooks/useAuth.ts |
useAuth() — consumes AuthContext |
src/components/require-auth/index.tsx |
wraps pages; calls beginLogin for unauthed, renders Alert for group mismatches |
src/pages/auth/callback/ |
MPA entry (index.html + main.tsx + app.tsx); handles ?code= param, redirects to returnTo or renders error Alert |
src/pages/meetings/components/jitsi-embed.tsx |
iframe embed component |
src/pages/meetings/components/meetings-table.tsx |
Join column + Cloudscape Modal that mounts the embed |
the Shell layout (src/layouts/shell/index.tsx) is wrapped with AuthProvider at the top level. TopNavigation gains sign-in/sign-out UI: a menu-dropdown when authenticated (with "moderator" description for moderators), a button when not
| resource | value |
|---|---|
| user pool | us-west-2_cyPQF4F3r |
| SPA app client | 57eikmt418ea6vti2f6h0pl74r |
| hosted UI domain | cloud-del-norte.auth.us-west-2.amazoncognito.com |
| token-exchange API | https://rwmypxz9z6.execute-api.us-west-2.amazonaws.com/token/jitsi |
Cognito user pool + Lambda token-exchange infra lives in chasko-labs/cloud-del-norte-meet (CDK, deploys to jitsi-video-hosting account 170473530355)
rotate jitsi JWT secret
the token-exchange Lambda reads the JWT signing secret from AWS Secrets Manager in account 170473530355 (jitsi account). to rotate: update the secret value in Secrets Manager, then redeploy or force a Lambda cold start. no code changes needed in this repo
adjust CSP / security headers
response headers policy is managed out-of-band via CloudFront. the policy definition lives in infra/cloudfront-security-headers.json. to apply changes:
AWS_PROFILE=aerospaceug-admin ./infra/apply-security-headers.shsee infra/README.md for the current policy state. changes take effect after CloudFront propagation (~60s), no invalidation required
deploy workflow
.github/workflows/deploy.yml triggers on pushes to main that touch src/, public/, or config files. the workflow builds, syncs to S3, then issues a CloudFront invalidation against distribution ECC3LP1BL2CZS. OIDC-federated role (AWS_ROLE_ARN secret) is used if present; the deploy step sets continue-on-error: true so a missing role won't fail the build check
removing the auth layer is reversible — no DB migrations, no external state owned by this repo:
- in
src/pages/meetings/app.tsx— unwrap<RequireAuth>from the page component - in
src/pages/create-meeting/app.tsx— same - in
src/layouts/shell/index.tsx— remove the<AuthProvider>wrapper - delete
src/lib/auth.ts,src/lib/jitsi-token.ts,src/contexts/auth-context.tsx,src/hooks/useAuth.ts,src/components/require-auth/,src/pages/auth/ - delete
src/pages/meetings/components/jitsi-embed.tsx; revertmeetings-table.tsxto drop the Join column - remove the auth callback entry from
vite.config.ts→build.rollupOptions.input
the Cognito pool, Lambda, and jitsi runtime are unaffected — they live in separate repos and accounts
This project uses Squad v0.5.4 with HeraldStack personas for AI-assisted development. Run copilot --agent squad to engage the team. See AGENTS.md for agent roles and routing.
See CONTRIBUTING.md for more information.
The sample code is available under a modified MIT license. See the LICENSE file.