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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,11 @@
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"builderMode": "application",
"main": "projects/request/src/test.ts",
"karmaConfig": "projects/request/karma.conf.js",
"tsConfig": "projects/request/tsconfig.spec.json",
"include": ["projects/request/src/**/*.spec.ts"],
"scripts": []
}
}
Expand Down
35 changes: 35 additions & 0 deletions docs/upgrades/angular-18-to-19-security-fixes.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,37 @@ review and update as needed:
- `Apollo.use()`, `Apollo.watchQuery()`, `Apollo.mutate()`, `Apollo.query()` signatures
- check for changes in `ApolloModule.forRoot()` vs standalone provider pattern

### 5.5. Lambda@Edge forwarder — S3 path prefix (COMPLETED March 2026)

**Root cause:** The Angular 19 `application` builder (`@angular-devkit/build-angular ^19`) changed the build output structure by introducing a `browser/` subdirectory:

| Builder | Angular version | Output path | S3 key prefix |
|---------|-----------------|-------------|---------------|
| `browser` (webpack) | 17 / 18 | `dist/v3/{locale}/` | `/{locale}/` |
| `application` (esbuild) | **19+** | `dist/v3/browser/{locale}/` | `/browser/{locale}/` |

This mismatch caused `lambda/forwarder/index.js` to rewrite all CloudFront URIs to non-existent S3 keys (e.g. `/en-US/index.html` instead of `/browser/en-US/index.html`). S3 returned `403 AccessDenied` — surfaced as raw XML to every user loading the app.

**Fix applied to `lambda/forwarder/index.js`:**

```javascript
// before (Angular 17/18)
request.uri = "/en-US/index.html"; // fallback
request.uri = `/${locale}/index.html`; // spa route
// (no static-asset rewrite needed — assets were at root)

// after (Angular 19+)
request.uri = "/browser/en-US/index.html"; // fallback
request.uri = `/browser/${locale}/index.html`; // spa route
} else if (!request.uri.startsWith('/browser/')) {
request.uri = `/browser${request.uri}`; // static assets
}
```

**Validation:** Confirmed working on `p2-stage` after deployment (March 2026).

**Future Angular upgrades:** check `ls dist/v3/` after building to verify the output structure before merging. If thenstructure changes again, update `forwarder/index.js` in the same PR. See `lambda/README.md` for the full dependency documentation.

---

## Phase 6 — Testing & Validation
Expand Down Expand Up @@ -446,6 +477,7 @@ npm audit > ./output/angular-19-upgrade-audit.log 2>&1
| TypeScript 5.6 stricter checks | incremental fix of any new type errors |
| ngx-quill v27 regressions | minimal risk — usually just peer dep bump |
| CI build failures | run full CI pipeline before merging |
| **Lambda@Edge S3 path mismatch** (**occurred** March 2026) | Angular 19 `application` builder adds `browser/` subdirectory; `lambda/forwarder/index.js` must be updated in the same PR — see §5.5 |

**Rollback plan:** revert to the pre-upgrade commit on `angular-eos-upgrades-prerelease` branch. all changes are confined to `package.json`, `package-lock.json`, and targeted source fixes.

Expand Down Expand Up @@ -480,10 +512,13 @@ npm audit > ./output/angular-19-upgrade-audit.log 2>&1
- [ ] update uppy service/component if API changed
- [ ] update apollo-angular usage if API changed
- [ ] fix any Angular 19 deprecation warnings
- [x] update `lambda/forwarder/index.js` for `browser/` path prefix *(completed March 2026)*
- [ ] **Phase 6:** Validation
- [ ] `npm run prebuildv3` succeeds
- [ ] `ng build v3` succeeds
- [ ] `npm test` passes
- [ ] `npm run lint` passes
- [ ] `npm audit` shows reduced vulnerability count
- [ ] manual testing on staging
- [ ] verify app loads at `https://app.<stack>.practera.com/en-US` without S3 AccessDenied
- [ ] verify deep-links with query params (magic-link login) resolve to correct locale
74 changes: 70 additions & 4 deletions lambda/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,76 @@
### Description

This directory will hold `lambda@edge` functions.
This directory holds `lambda@edge` functions deployed to CloudFront as `origin-request` handlers.

`forwarder` - lambda function that sits infront of the CDN, handles `globalization` redirection.
`versioner` - function to create lambda function version.
`forwarder` - rewrites incoming CloudFront URIs to the correct S3 object keys for the Angular i18n locale builds.
`versioner` - creates a numbered Lambda version ARN that CloudFront requires for Lambda@Edge associations.

---

### i18n / Angular Build Output Dependency

The `forwarder` function is **tightly coupled** to the Angular build output directory structure. Breaking this coupling causes S3 `AccessDenied` (403) errors surfaced as raw XML to end users.

#### How Angular i18n builds are deployed

`projects/v3` is compiled with `"localize": true` in `angular.json`, producing one subfolder per locale:

| Angular version | Build output path | S3 object key prefix |
|-----------------|-------------------|----------------------|
| 17 / 18 (webpack builder) | `dist/v3/{locale}/` | `/{locale}/` |
| **19+ (application builder)** | `dist/v3/browser/{locale}/` | `/browser/{locale}/` |

The `aws s3 sync dist/v3/ s3://$BUCKET --delete` step (CI/CD step 22) mirrors this structure directly into S3, so the S3 key prefix always matches the build output.

#### What the forwarder does

CloudFront does **not** forward query strings to S3 (`QueryString: false`). When a user visits a deep-link such as `/en-US?auth_token=…`, CloudFront calls the forwarder with `request.uri = "/en-US"`. The forwarder:

1. Extracts the first path segment as the locale (`en-US`, `ja`, `ms`, `es`).
2. For unknown locales or bare `/`, falls back to the default locale.
3. For SPA routes (no file extension), rewrites to `/{prefix}/{locale}/index.html`.
4. For static assets (with file extension), rewrites to `/{prefix}{original_uri}`.
5. Passes `version.json` requests through unchanged (whitelist).

#### Supported locales

The locale whitelist in `forwarder/index.js` must stay in sync with the locales configured in `angular.json`:

```javascript
const locales = ["en-US", "ja", "ms", "es"];
```

To add a new locale: update both `angular.json` (`i18n.locales`) **and** the `locales` array in `forwarder/index.js`.

#### ⚠️ What to check on every Angular major version upgrade

When upgrading Angular major versions, verify the build output structure has not changed before deploying:

```bash
# after building, check what subdirectories are produced
ls dist/v3/
# Angular 19+: should show browser/
# Angular 17/18: should show en-US/ ja/ ms/ es/
```

If the output structure changes, update the path prefix in `forwarder/index.js` **before** merging to trunk so both changes deploy together in the same CI/CD run.

#### Incident history

| Date | Trigger | Symptom | Fix |
|------|---------|---------|-----|
| March 2026 | Angular 18 → 19 upgrade | S3 `AccessDenied` XML shown to all users on `app.p2-stage.practera.com` | Added `/browser/` prefix to all rewritten URIs in `forwarder/index.js` |

---

### Deployment

Once `AWS` credentials is ready, just run `deploy.sh`. Make sure you installed [sam](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) on your machine.
Once `AWS` credentials are ready, run `deploy.sh`. Requires [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html).

In CI/CD (`p2-stage-appv3.yml`) the deploy sequence is:
1. Build Angular app (`ng build v3`)
2. Deploy Lambda@Edge (`bash lambda/deploy.sh`) → exports `HandlerVersionArn`
3. Deploy CloudFormation/Serverless stack (picks up new `HandlerVersionArn`)
4. Sync `dist/v3/` to S3

Both the Lambda function and the S3 content must reflect the same output path structure.
15 changes: 8 additions & 7 deletions lambda/forwarder/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const path = require('path');

exports.handler = async (evt, context, cb) => {
exports.handler = async (evt) => {
const { request } = evt.Records[0].cf;

console.log(`Original Uri: ${request.uri}`);
Expand All @@ -11,26 +11,27 @@ exports.handler = async (evt, context, cb) => {
const locales = ["en-US", "ja", "ms", "es"];
const lastPartUrl = uriParts[uriParts.length - 1];

// whitelisted version.json request
// whitelisted version.json request — note: query strings are in request.querystring,
// not in request.uri, so only the path filename is tested here
console.log("trailingURL::", lastPartUrl);
if (lastPartUrl.match(/^version\.json(?:\?t=\d+)?$/) !== null) {
return cb(null, request);
if (lastPartUrl.match(/^version\.json$/) !== null) {
return request;
}

if (locale === "" || locale === "index.html" || !locales.includes(locale)) {
request.uri = "/browser/en-US/index.html";
console.log("Go to default page and locale.");
return cb(null, request);
return request;
}

const fileExt = path.extname(lastPartUrl);
if (!fileExt) {
request.uri = `/browser/${locale}/index.html`;
} else if (!request.uri.startsWith('/browser/')) {
// rewrite static asset paths to match Angular 17+ application builder output
// rewrite static asset paths to match Angular 19+ application builder output
request.uri = `/browser${request.uri}`;
}

console.log(`New Uri: ${request.uri}`);
return cb(null, request);
return request;
};
4 changes: 2 additions & 2 deletions lambda/forwarder/template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Resources:
Properties:
Handler: index.handler
CodeUri: ./bin/handler.zip
Runtime: nodejs18.x
Runtime: nodejs22.x
Timeout: 10
Role: !GetAtt HandlerRole.Arn
HandlerVersion:
Expand All @@ -53,4 +53,4 @@ Outputs:
Description: "Arn Version for Lambda function to associate unto CDN"
Value: !GetAtt HandlerVersion.FunctionArn
Export:
Name: !Sub "${StackName}-HandlerVersion-${Env}"
Name: !Sub "${StackName}-HandlerVersion-${Env}"
4 changes: 2 additions & 2 deletions lambda/versioner/template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Resources:
Properties:
Handler: index.handler
CodeUri: ./bin/handler.zip
Runtime: nodejs18.x
Runtime: nodejs22.x
Timeout: 10
Role: !GetAtt LambdaVersionHelperRole.Arn
LambdaVersionHelperRole:
Expand Down Expand Up @@ -51,4 +51,4 @@ Outputs:
Description: "Lambda Function Versioner ARN"
Value: !GetAtt Versioner.Arn
Export:
Name: !Sub "${StackName}-LambdaVersionerArn-${Env}"
Name: !Sub "${StackName}-LambdaVersionerArn-${Env}"
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@
"core-js": "^3.21.1",
"dayjs": "^1.11.10",
"exif-js": "^2.3.0",
"filestack-js": "^3.30.0",
"franc-min": "^6.2.0",
"graphql": "^16.8.1",
"ics": "^3.7.2",
Expand Down
13 changes: 7 additions & 6 deletions projects/request/karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,8 @@ module.exports = function (config) {
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
random: false,
timeoutInterval: 10000,
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
Expand All @@ -37,8 +35,11 @@ module.exports = function (config) {
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
browsers: ['ChromeHeadless'],
singleRun: false,
restartOnFileChange: true
restartOnFileChange: true,
browserNoActivityTimeout: 120000,
browserDisconnectTimeout: 30000,
browserDisconnectTolerance: 3,
});
};
5 changes: 1 addition & 4 deletions projects/request/ng-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,5 @@
"dest": "../../dist/request",
"lib": {
"entryFile": "src/public-api.ts"
},
"allowedNonPeerDependencies": [
"lodash"
]
}
}
5 changes: 2 additions & 3 deletions projects/request/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
"name": "request",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@angular/core": "^17.0.0 || ^18.0.0 || ^19.0.0"
"@angular/common": "^18.0.0 || ^19.0.0",
"@angular/core": "^18.0.0 || ^19.0.0"
},
"dependencies": {
"lodash-es": "^4.17.21",
"tslib": "^2.3.0"
}
}
6 changes: 6 additions & 0 deletions projects/request/src/lib/request.module.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { RequestModule } from './request.module';

describe('RequestModule', () => {
Expand All @@ -12,6 +14,10 @@ describe('RequestModule', () => {
prefixUrl: 'TEST',
}),
],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
],
});

requestModule = TestBed.inject(RequestModule);
Expand Down
7 changes: 0 additions & 7 deletions projects/request/src/lib/request.module.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { RequestConfig, RequestService } from './request.service';
import { HttpClientModule } from '@angular/common/http';

@NgModule({
imports: [
HttpClientModule,
],
providers: [
RequestService,
],
exports: [
HttpClientModule,
],
})
export class RequestModule {
constructor(@Optional() @SkipSelf() parentModule: RequestModule) {
Expand Down
Loading
Loading