From 9579c79194efb8a4f8902a419b4147762d97ffbb Mon Sep 17 00:00:00 2001 From: andrewwestgard-tech Date: Mon, 13 Jul 2026 11:34:18 -0600 Subject: [PATCH 1/4] feat: add audit-log-viewer example app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Audit Log Viewer as a self-hosted reference app under examples/audit-log-viewer. The app reads Contentful audit log files from a customer-owned cloud storage destination (AWS S3, Azure Blob Storage, or Google Cloud Storage) and renders them in a filterable, paginated table with charts — all from inside the Contentful web app. A Contentful-hosted App Action Function holds cloud credentials as Secret installation parameters and generates short-lived signed URLs; the browser never sees the credentials directly. Co-Authored-By: Claude Fable 5 --- examples/audit-log-viewer/.gitignore | 8 + examples/audit-log-viewer/README.md | 359 ++++++++++++++++ examples/audit-log-viewer/assets/logo.png | Bin 0 -> 19249 bytes examples/audit-log-viewer/assets/logo.svg | 53 +++ .../contentful-app-manifest.json | 18 + .../__tests__/auditLogBroker.test.ts | 141 +++++++ .../functions/__tests__/azure.test.ts | 175 ++++++++ .../functions/__tests__/filenames.test.ts | 28 ++ .../functions/__tests__/gcs.test.ts | 203 +++++++++ .../functions/__tests__/s3.test.ts | 85 ++++ .../functions/__tests__/select.test.ts | 32 ++ .../functions/__tests__/webcrypto.test.ts | 63 +++ .../functions/auditLogBroker.ts | 90 ++++ .../functions/lib/filenames.ts | 15 + .../functions/lib/storage/azure.ts | 167 ++++++++ .../functions/lib/storage/factory.ts | 16 + .../functions/lib/storage/gcs.ts | 183 +++++++++ .../functions/lib/storage/s3.ts | 111 +++++ .../functions/lib/storage/select.ts | 25 ++ .../functions/lib/storage/types.ts | 45 ++ .../functions/lib/storage/webcrypto.ts | 58 +++ .../audit-log-viewer/functions/tsconfig.json | 5 + examples/audit-log-viewer/index.html | 20 + examples/audit-log-viewer/package.json | 71 ++++ .../scripts/configure-app.mjs | 56 +++ .../audit-log-viewer/scripts/install-app.mjs | 61 +++ .../scripts/invoke-action.mjs | 34 ++ .../audit-log-viewer/scripts/set-app-icon.mjs | 23 ++ examples/audit-log-viewer/src/App.tsx | 28 ++ .../src/components/ChartsPanel.test.tsx | 37 ++ .../src/components/ChartsPanel.tsx | 152 +++++++ .../src/components/EventsTable.test.tsx | 52 +++ .../src/components/EventsTable.tsx | 111 +++++ .../src/components/LocalhostWarning.tsx | 32 ++ examples/audit-log-viewer/src/index.tsx | 21 + .../src/lib/configParams.test.ts | 145 +++++++ .../audit-log-viewer/src/lib/configParams.ts | 113 +++++ .../src/lib/directory.test.ts | 55 +++ .../audit-log-viewer/src/lib/directory.ts | 58 +++ .../audit-log-viewer/src/lib/events.test.ts | 130 ++++++ examples/audit-log-viewer/src/lib/events.ts | 113 +++++ .../audit-log-viewer/src/lib/invokeAction.ts | 38 ++ .../src/lib/parseLogFile.test.ts | 35 ++ .../audit-log-viewer/src/lib/parseLogFile.ts | 31 ++ examples/audit-log-viewer/src/lib/types.ts | 13 + .../audit-log-viewer/src/lib/useAuditLogs.ts | 71 ++++ .../src/locations/ConfigScreen.spec.tsx | 26 ++ .../src/locations/ConfigScreen.tsx | 387 ++++++++++++++++++ .../src/locations/Dialog.spec.tsx | 17 + .../audit-log-viewer/src/locations/Dialog.tsx | 16 + .../src/locations/Page.spec.tsx | 17 + .../audit-log-viewer/src/locations/Page.tsx | 158 +++++++ examples/audit-log-viewer/src/setupTests.ts | 14 + examples/audit-log-viewer/test/mocks/index.ts | 2 + .../audit-log-viewer/test/mocks/mockCma.ts | 3 + .../audit-log-viewer/test/mocks/mockSdk.ts | 19 + examples/audit-log-viewer/tsconfig.json | 18 + examples/audit-log-viewer/vite.config.mts | 19 + 58 files changed, 4076 insertions(+) create mode 100644 examples/audit-log-viewer/.gitignore create mode 100644 examples/audit-log-viewer/README.md create mode 100644 examples/audit-log-viewer/assets/logo.png create mode 100644 examples/audit-log-viewer/assets/logo.svg create mode 100644 examples/audit-log-viewer/contentful-app-manifest.json create mode 100644 examples/audit-log-viewer/functions/__tests__/auditLogBroker.test.ts create mode 100644 examples/audit-log-viewer/functions/__tests__/azure.test.ts create mode 100644 examples/audit-log-viewer/functions/__tests__/filenames.test.ts create mode 100644 examples/audit-log-viewer/functions/__tests__/gcs.test.ts create mode 100644 examples/audit-log-viewer/functions/__tests__/s3.test.ts create mode 100644 examples/audit-log-viewer/functions/__tests__/select.test.ts create mode 100644 examples/audit-log-viewer/functions/__tests__/webcrypto.test.ts create mode 100644 examples/audit-log-viewer/functions/auditLogBroker.ts create mode 100644 examples/audit-log-viewer/functions/lib/filenames.ts create mode 100644 examples/audit-log-viewer/functions/lib/storage/azure.ts create mode 100644 examples/audit-log-viewer/functions/lib/storage/factory.ts create mode 100644 examples/audit-log-viewer/functions/lib/storage/gcs.ts create mode 100644 examples/audit-log-viewer/functions/lib/storage/s3.ts create mode 100644 examples/audit-log-viewer/functions/lib/storage/select.ts create mode 100644 examples/audit-log-viewer/functions/lib/storage/types.ts create mode 100644 examples/audit-log-viewer/functions/lib/storage/webcrypto.ts create mode 100644 examples/audit-log-viewer/functions/tsconfig.json create mode 100644 examples/audit-log-viewer/index.html create mode 100644 examples/audit-log-viewer/package.json create mode 100644 examples/audit-log-viewer/scripts/configure-app.mjs create mode 100644 examples/audit-log-viewer/scripts/install-app.mjs create mode 100644 examples/audit-log-viewer/scripts/invoke-action.mjs create mode 100644 examples/audit-log-viewer/scripts/set-app-icon.mjs create mode 100644 examples/audit-log-viewer/src/App.tsx create mode 100644 examples/audit-log-viewer/src/components/ChartsPanel.test.tsx create mode 100644 examples/audit-log-viewer/src/components/ChartsPanel.tsx create mode 100644 examples/audit-log-viewer/src/components/EventsTable.test.tsx create mode 100644 examples/audit-log-viewer/src/components/EventsTable.tsx create mode 100644 examples/audit-log-viewer/src/components/LocalhostWarning.tsx create mode 100644 examples/audit-log-viewer/src/index.tsx create mode 100644 examples/audit-log-viewer/src/lib/configParams.test.ts create mode 100644 examples/audit-log-viewer/src/lib/configParams.ts create mode 100644 examples/audit-log-viewer/src/lib/directory.test.ts create mode 100644 examples/audit-log-viewer/src/lib/directory.ts create mode 100644 examples/audit-log-viewer/src/lib/events.test.ts create mode 100644 examples/audit-log-viewer/src/lib/events.ts create mode 100644 examples/audit-log-viewer/src/lib/invokeAction.ts create mode 100644 examples/audit-log-viewer/src/lib/parseLogFile.test.ts create mode 100644 examples/audit-log-viewer/src/lib/parseLogFile.ts create mode 100644 examples/audit-log-viewer/src/lib/types.ts create mode 100644 examples/audit-log-viewer/src/lib/useAuditLogs.ts create mode 100644 examples/audit-log-viewer/src/locations/ConfigScreen.spec.tsx create mode 100644 examples/audit-log-viewer/src/locations/ConfigScreen.tsx create mode 100644 examples/audit-log-viewer/src/locations/Dialog.spec.tsx create mode 100644 examples/audit-log-viewer/src/locations/Dialog.tsx create mode 100644 examples/audit-log-viewer/src/locations/Page.spec.tsx create mode 100644 examples/audit-log-viewer/src/locations/Page.tsx create mode 100644 examples/audit-log-viewer/src/setupTests.ts create mode 100644 examples/audit-log-viewer/test/mocks/index.ts create mode 100644 examples/audit-log-viewer/test/mocks/mockCma.ts create mode 100644 examples/audit-log-viewer/test/mocks/mockSdk.ts create mode 100644 examples/audit-log-viewer/tsconfig.json create mode 100644 examples/audit-log-viewer/vite.config.mts diff --git a/examples/audit-log-viewer/.gitignore b/examples/audit-log-viewer/.gitignore new file mode 100644 index 0000000000..b6badc9fc4 --- /dev/null +++ b/examples/audit-log-viewer/.gitignore @@ -0,0 +1,8 @@ +node_modules +build +.env +.env.local +*_accessKeys.csv +contentful-audit-*.json +# GCP service-account keys — never commit +coffee-review-*.json diff --git a/examples/audit-log-viewer/README.md b/examples/audit-log-viewer/README.md new file mode 100644 index 0000000000..dfa59ad5d7 --- /dev/null +++ b/examples/audit-log-viewer/README.md @@ -0,0 +1,359 @@ +# Audit Log Viewer — Contentful App + +View and filter your organisation's Contentful audit logs — delivered to your +own **AWS S3 bucket, Azure Blob container, or Google Cloud Storage bucket** — +from a page inside the Contentful web app. + +**Security model:** the browser never sees cloud credentials. A +Contentful-hosted App Action Function holds them (secure installation +parameters), lists matching log files, and returns short-lived (15 min) +time-limited GET URLs (S3 presigned / Azure SAS / GCS V4-signed). The browser +fetches and parses the log files itself and renders the table and charts. + +``` +┌─────────────────────┐ app action call ┌──────────────────────────┐ +│ Page location (React│ ───────────────────► │ Function "auditLogBroker"│ +│ + Forma 36, iframe) │ ◄─────────────────── │ (Contentful-hosted) │ +│ no secrets │ signed URLs │ Secret params → sign │ +└─────────┬───────────┘ └────────────┬─────────────┘ + │ GET (signed URL, CORS) │ list objects + ▼ ▼ + ┌──────────── your S3 bucket / Azure container / GCS bucket ─────┐ + │ contentful-audit--.json (daily) │ + └────────────────────────────────────────────────────────────────┘ +``` + +### Access model — read before installing + +Installing this app in a space exposes the **entire organization's** audit +logs (all spaces, all users' actions) to **every user** who can access that +space's Apps/page locations. The Function performs no per-user authorization +of its own — any member of a space where the app is installed can invoke the +action and read the whole org's audit logs. **Install it only into a +restricted, admin-only space.** + +## Storage providers + +| provider | list mechanism | browser URLs | credentials (Secret params) | status | +|---|---|---|---|---| +| `s3` (default) | ListObjectsV2 (AWS SDK v3) | presigned GET, 15 min | `awsAccessKeyId` + `awsSecretAccessKey` (optional STS role) | **verified live** | +| `azure` | List Blobs REST + container SAS | per-blob SAS, 15 min | `azureAccountKey` | **verified live** (2026-07-06: listing + SAS download against a real storage account) | +| `gcs` | JSON API + OAuth JWT grant | V4 signed URLs, 15 min | `gcsServiceAccountKey` (JSON) | **verified live** (2026-07-06: listing, signed-URL download and in-app browser view against a real bucket) | + +Two live-verification findings baked into the code: the Functions runtime +rejects calls to a detached `fetch` reference ("Illegal invocation") — both +providers wrap it — and Azure prefixes its XML responses with a UTF-8 BOM, +which is stripped before parsing. + +All three providers are built on plain `fetch` + WebCrypto (no provider +SDKs; S3 uses the AWS SDK) and have been verified against live storage: +listing, time-limited URL minting, and download. + +## Quick start — from zero to reading data + +1. **Contentful side:** an org admin configures audit-log delivery + (Organization settings → Audit logs) to your S3 bucket / Azure container / + GCS bucket — see Contentful's setup guide in Prerequisites. Files arrive + daily; each covers the previous day. (For a dry run you can skip this and + upload any correctly-named `contentful-audit-*.json` file yourself.) +2. **Cloud side:** follow the provider section below — create a read-only + machine credential and set the storage CORS rule. +3. **Deploy the app** (once per org): "Create the app definition & deploy". +4. **Install + configure:** install the app into an admin-only space, pick + your provider on the configuration screen, enter the credential, save. +5. **Read the data:** open the space's Apps menu → Audit Log Viewer, set a + date range covering delivered files, click **Load logs**. Filter by + space/actor/action or search; charts and table follow the filters. + +## AWS SDK compatibility in the Contentful Functions runtime + +The Step-1 gate test passed on 2026-07-02 (full record: `docs/superpowers/plans/gate-result.md` in the repo root). `@aws-sdk/client-s3`, `@aws-sdk/client-sts` and `@aws-sdk/s3-request-presigner` (v3.1078) all run inside the Contentful Functions runtime, with two required adjustments: + +1. Every AWS client must be constructed with `requestHandler: new FetchHttpHandler()` (`@smithy/fetch-http-handler`) — the runtime has no `node:http`/`node:https`. +2. The runtime lacks the DOM globals the SDK's browser build uses to deserialize S3's XML responses. `DOMParser` and `Node` are polyfilled from `@xmldom/xmldom` at module load (see `functions/lib/storage/s3.ts`); without this the SDK fails with `ReferenceError: DOMParser is not defined`. + +The manifest's `allowNetworks: ["*.amazonaws.com"]` wildcard was accepted at build, upload and runtime. One platform quirk to know: **activating a new bundle wipes the app installation's parameters** — after every app update, re-save the configuration screen (or in development run `npm run install-app`, which re-installs the app with parameters from `.env`). + +## Prerequisites + +- Contentful org on a plan with **Functions** (Premium) and **Audit Logs** + enabled, with audit-log delivery configured to one of the three supported + destinations + (https://www.contentful.com/developers/docs/tutorials/general/audit-logs/). +- Node 20+, a CMA token with org admin rights (deploy time only). +- Rights in your cloud account to create one read-only machine credential: + AWS IAM user, Azure storage-account key access, or a GCP service account. + +## AWS IAM setup (customer side) + +### 1. Create the IAM user (AWS Console walkthrough) + +1. **IAM → Users → Create user.** Name it e.g. `audit-logs-visualiser`. + Leave **"Provide user access to the AWS Management Console" UNCHECKED** — + this is a machine identity for the app, not a person (unchecking also + skips the Identity Center prompt). +2. On **Set permissions**, choose **Attach policies directly → Create policy + → JSON** and paste the read-only policy below (opens in a new tab; after + creating it, refresh the policy list in the original tab, tick it, then + **Next → Create user**): + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::" }, + { "Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::/*" } + ] +} +``` + +3. Open the created user → **Security credentials → Access keys → Create + access key** → use case **"Application running outside AWS"** (or + "Other"). Copy the **Access key ID** and **Secret access key** immediately + — the secret is shown only once. These two values go into the app's + configuration screen (stored as Secret installation parameters). + +This direct-key setup is the verified, tested path. The key can list and +read one bucket and nothing else. + +### 2. Optional hardening: STS role assumption + +Instead of attaching the read policy to the user directly, you can make the +user's *only* permission `sts:AssumeRole` into a read-only role — the app +then assumes the role at request time (fill "role ARN" + "external ID" in +the app config): + +- Role `contentful-audit-log-reader` carries the read-only policy from step 1. +- The user gets only: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { "Effect": "Allow", "Action": "sts:AssumeRole", "Resource": "arn:aws:iam:::role/contentful-audit-log-reader" } + ] +} +``` + +- Role trust policy (use any random string as ExternalId and enter it in the app config): + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam:::user/" }, + "Action": "sts:AssumeRole", + "Condition": { "StringEquals": { "sts:ExternalId": "" } } + } + ] +} +``` + +### 3. Bucket CORS (required) + +Required because editors' browsers download the log files. + Contentful serves hosted app bundles from a sandboxed per-app origin on + `ctfcloud.net` (not `app.contentful.com`), so both origins are needed: + +```json +[ + { + "AllowedHeaders": ["*"], + "AllowedMethods": ["GET", "HEAD"], + "AllowedOrigins": ["https://app.contentful.com", "https://*.ctfcloud.net"], + "ExposeHeaders": [], + "MaxAgeSeconds": 3000 + } +] +``` + +### 4. Optional IP allowlisting + +The Function's outbound calls always come from + Contentful's static egress IPs: `104.28.4.4/32`, `104.28.4.5/32`, + `104.28.4.6/32`, `104.28.4.7/32`, `2a09:bac5:fff0:95::/64`, + `2a09:bac6:fff0:95::/64`. You may add an `aws:SourceIp` condition for these + on `s3:ListBucket` / `sts:AssumeRole`. **Do not** put an IP condition on + `s3:GetObject`: pre-signed downloads come from your editors' browser IPs. + +## Azure Blob setup (customer side) + +1. **Storage account** — Create a resource → Storage account (Standard + performance, LRS is fine). The account name must be **3–24 lowercase + letters/digits** — the app validates this and rejects anything else. + Wait for the deployment to complete (~1 minute), then **Go to resource**. +2. **Container** — storage account → Containers → **+ Container** (private + access level, the default). This is the container Contentful delivers + audit logs into. +3. **Access key** — storage account left menu → **Security + networking → + Access keys** → **Show keys** → copy **key1**'s *Key* value (the long + base64 string — not the connection string). This is the only credential + the app needs; it stays server-side and browsers only ever receive + 15-minute single-blob SAS URLs. Note: Access keys and CORS are blades + *inside the storage account resource*, not entries in the global "All + services" catalog — open the storage account first. +4. **Blob CORS** — storage account left menu → **Settings → Resource sharing + (CORS)** → **Blob service** tab → add a row and Save: + + | Allowed origins | Allowed methods | Allowed headers | Exposed headers | Max age | + |---|---|---|---|---| + | `https://app.contentful.com,https://*.ctfcloud.net` | `GET,HEAD` | `*` | `*` | `3000` | + +5. **App config screen:** provider "Azure Blob Storage" → account name, + container name, account key → Save. +6. Housekeeping: Azure lets you rotate key1/key2 independently (Access keys → + Rotate) — if the key is ever exposed, rotate it and re-enter it in the + app configuration. + +## Google Cloud Storage setup (customer side) + +1. Create a service account (IAM & Admin → Service Accounts → Create; no + project-level roles needed). +2. Grant it access on the **bucket**: bucket → Permissions → Grant access → + the service account's email → role **Storage Object Viewer**. Watch the + role name: **"Storage Viewer" is a different role** that can read bucket + metadata but NOT list objects — it fails with + `storage.objects.list denied`. You need Storage **Object** Viewer. +3. Service account → Keys → **Add key → JSON**; paste the downloaded file's + entire content into the app config screen ("Service account key (JSON)"). +4. **Bucket CORS** — required for browser downloads, and it **cannot be set + from the Cloud Console UI**; use Cloud Shell (the `>_` icon in the console + toolbar) or a local `gcloud`: + +```bash +echo '[{"origin": ["*"], "method": ["GET", "HEAD"], "maxAgeSeconds": 3000}]' > cors.json +gcloud storage buckets update gs:// --cors-file=cors.json +``` + + (Alternatively PATCH the bucket via the JSON API with a token whose role + temporarily includes bucket update rights, then drop the role again.) + The applied policy, for reference: + +```json +[ + { + "origin": ["*"], + "method": ["GET", "HEAD"], + "maxAgeSeconds": 3000 + } +] +``` + +GCS CORS does not support wildcard subdomains, and the Contentful app iframe runs on a per-app ctfcloud.net origin, so "*" is required. This is safe: the URLs themselves are auth'd by their V4 signature and expire after 15 minutes — CORS adds no access control here. + +## Create the app definition & deploy + +```bash +npm install +npm run create-app-definition # name: Audit Log Viewer; locations: Page + App configuration screen +# put CONTENTFUL_ACCESS_TOKEN / CONTENTFUL_ORG_ID / CONTENTFUL_APP_DEF_ID +# / CONTENTFUL_SPACE_ID in .env +# for `npm run install-app` (dev), also add AWS_BUCKET_NAME / AWS_REGION / +# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ optional AWS_ROLE_ARN, +# AWS_EXTERNAL_ID, AWS_PREFIX) — these mirror the installation parameters +# you would otherwise enter on the app configuration screen +npm run build && npm run upload # bundles frontend + function, activates +npm run configure-app # installation parameters + app action +npm run set-app-icon # app icon from assets/logo.png (AppDetails + # wants a data URI, not raw base64) +``` + +## Install & configure + +Install the app into a space (Apps → Custom apps). On the configuration +screen, pick your **storage provider** from the dropdown — the credential +fields switch accordingly: + +- **Amazon S3:** bucket name, region, access key id + secret (optional role + ARN + external ID for STS hardening). +- **Azure Blob Storage:** storage account name, container name, account key. +- **Google Cloud Storage:** bucket name, service-account key (paste the whole + JSON file). + +All credential fields are stored as **Secret** installation parameters. An +optional key prefix applies to every provider (only if your files live under +a folder; must end with `/`). The screen also shows the provider-specific +setup steps (IAM policy / CORS / egress IPs) to copy. Two behaviors to know: + +- **Switching provider replaces the saved configuration** — the previous + provider's credentials are removed on save and must be re-entered if you + switch back. +- **After every app update** (new bundle activation) Contentful clears the + saved parameters — revisit this screen and save again. + +Saving the configuration without retyping the secrets **preserves the stored values**: verified against the platform — re-sending the redacted placeholders on save leaves the original secrets intact and the Function continues to authenticate. + +## Installation parameters + +| id | type | required | purpose | +|---|---|---|---| +| `bucketName` | Symbol | yes | audit-log bucket | +| `region` | Symbol | yes | bucket region, e.g. `eu-west-1` | +| `prefix` | Symbol | no | key prefix if files live under a folder (end with `/`) | +| `roleArn` | Symbol | no | read-only role the function assumes | +| `externalId` | Symbol | no | STS external id for the trust policy | +| `awsAccessKeyId` | **Secret** | yes | never readable by the browser | +| `awsSecretAccessKey` | **Secret** | yes | never readable by the browser | +| `provider` | Symbol | no | `s3` (default), `azure`, or `gcs` | +| `azureAccountName` | Symbol | azure | storage account name | +| `azureContainerName` | Symbol | azure | container receiving the audit logs | +| `azureAccountKey` | **Secret** | azure | storage account access key | +| `gcsBucketName` | Symbol | gcs | bucket receiving the audit logs | +| `gcsServiceAccountKey` | **Secret** | gcs | full JSON key of a Storage Object Viewer service account | + +## Local development + +```bash +npm run dev # frontend on http://localhost:3000 (set as app URL for dev) +npx vitest run # unit tests (function + parser + UI) +npm run invoke -- 2026-06-01 2026-06-30 # smoke-invoke the deployed action +npm run install-app # re-install app + parameters from .env (needed after every upload) +``` + +The Function cannot run locally (no emulator) — the dev loop is: edit → +`npm run build && npm run upload` → `npm run invoke`. + +`npm run install-app` reads its provider from `.env`. For S3 (default) use +the `AWS_*` variables shown above. To install against Azure instead, set +`PROVIDER=azure` plus `AZURE_ACCOUNT_NAME` / `AZURE_CONTAINER_NAME` / +`AZURE_ACCOUNT_KEY`. To install against GCS, set `PROVIDER=gcs` plus +`GCS_BUCKET_NAME` / `GCS_SERVICE_ACCOUNT_KEY_FILE` (path to the downloaded +service account JSON key file). + +## Features + +- Date-range loading with per-file progress; filters for **space, actor, + action**, plus a free-text **search** over entity IDs, request paths, actor + and space names — charts, table and event count all follow the filtered set. +- Three charts (events over time, top actors, actions) and a paginated table + showing time, action, actor, entity, space and request per event. +- **Name resolution:** actor IDs resolve to real names via the current + space's member list, and the current space's ID resolves to its name. + Contentful blocks org-scoped CMA calls from inside app iframes ("You can + not access the action … from within an app"), so actors who are not + members of the space the app runs in — and users who have left the org — + remain as raw IDs. Composite IDs like `space/master/appId` are app + (machine) identities. + +## Design notes & limits + +- The app displays **whatever audit logs are in the bucket** — Contentful + delivers one org's logs per configured destination, but the viewer itself + does not filter by org, and the org that delivers logs does not need to be + the org where the app is installed. (Contentful's "Test connection" file + `contentful-audit-logging-connection-test.txt` is ignored by the filename + filter.) +- One app action `listAuditLogFiles(startDate, endDate)` returns at most 120 + presigned URLs (`truncated: true` beyond that — narrow the range). +- Audit files are flat daily objects named + `contentful-audit--.json`; each covers the **previous** day. + Date filtering parses the filename; there is no key partitioning. +- The parser tolerates gzip/plain and JSON-array/single-object/NDJSON layouts, + since Contentful documents only ".json". +- Aggregation happens client-side from the already-downloaded events, keeping + the Function inside its CPU/time limits and the 32 MB response cap. +- Storage access is behind `functions/lib/storage/` (`LogStorageProvider`); + S3, Azure Blob and GCS are implemented, and further providers can be added + without touching the handler or UI. diff --git a/examples/audit-log-viewer/assets/logo.png b/examples/audit-log-viewer/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5b8104a60961c6b00b8da2bbe395b540367576b6 GIT binary patch literal 19249 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 literal 0 HcmV?d00001 diff --git a/examples/audit-log-viewer/assets/logo.svg b/examples/audit-log-viewer/assets/logo.svg new file mode 100644 index 0000000000..2a22d3bf8b --- /dev/null +++ b/examples/audit-log-viewer/assets/logo.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/audit-log-viewer/contentful-app-manifest.json b/examples/audit-log-viewer/contentful-app-manifest.json new file mode 100644 index 0000000000..bf11e9744c --- /dev/null +++ b/examples/audit-log-viewer/contentful-app-manifest.json @@ -0,0 +1,18 @@ +{ + "functions": [ + { + "id": "auditLogBroker", + "name": "Audit Log Broker", + "description": "Lists audit log files in the customer S3 bucket for a date range and returns short-lived pre-signed GET URLs. Never returns credentials.", + "path": "functions/auditLogBroker.js", + "entryFile": "functions/auditLogBroker.ts", + "allowNetworks": [ + "*.amazonaws.com", + "*.windows.net", + "storage.googleapis.com", + "oauth2.googleapis.com" + ], + "accepts": ["appaction.call"] + } + ] +} diff --git a/examples/audit-log-viewer/functions/__tests__/auditLogBroker.test.ts b/examples/audit-log-viewer/functions/__tests__/auditLogBroker.test.ts new file mode 100644 index 0000000000..82b5beb2da --- /dev/null +++ b/examples/audit-log-viewer/functions/__tests__/auditLogBroker.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; +import { makeHandler } from '../auditLogBroker'; + +const goodParams = { + bucketName: 'b', + region: 'eu-west-1', + awsAccessKeyId: 'AKIA', + awsSecretAccessKey: 's', +}; + +const event = (body: Record) => + ({ type: 'appaction.call', headers: {}, body }) as never; +const context = (params: Record) => + ({ spaceId: 'sp', environmentId: 'master', appInstallationParameters: params }) as never; + +describe('auditLogBroker handler', () => { + it('returns files from storage for a valid range', async () => { + const listLogFiles = vi.fn(async () => ({ + files: [{ key: 'k', url: 'u', size: 1, coveredDate: '2026-06-02' }], + truncated: false, + })); + const handler = makeHandler(() => ({ listLogFiles })); + const res = await handler(event({ startDate: '2026-06-01', endDate: '2026-06-10' }), context(goodParams)); + expect(res).toEqual({ + ok: true, + files: [{ key: 'k', url: 'u', size: 1, coveredDate: '2026-06-02' }], + truncated: false, + }); + expect(listLogFiles).toHaveBeenCalledWith('2026-06-01', '2026-06-10'); + }); + + it('rejects malformed or inverted date ranges without calling storage', async () => { + const listLogFiles = vi.fn(); + const handler = makeHandler(() => ({ listLogFiles })); + for (const body of [ + {}, + { startDate: 'junk', endDate: '2026-06-10' }, + { startDate: '2026-06-10', endDate: '2026-06-01' }, + ]) { + const res = (await handler(event(body), context(goodParams))) as { ok: boolean }; + expect(res.ok).toBe(false); + } + expect(listLogFiles).not.toHaveBeenCalled(); + }); + + it('reports missing installation parameters by name', async () => { + const handler = makeHandler(() => ({ listLogFiles: vi.fn() })); + const res = (await handler( + event({ startDate: '2026-06-01', endDate: '2026-06-10' }), + context({ bucketName: 'b' }), + )) as { ok: boolean; error: string }; + expect(res.ok).toBe(false); + expect(res.error).toContain('region'); + }); + + it('converts storage errors into { ok:false } without a stack', async () => { + const handler = makeHandler(() => ({ + listLogFiles: vi.fn(async () => { + throw new Error('AccessDenied'); + }), + })); + const res = (await handler( + event({ startDate: '2026-06-01', endDate: '2026-06-10' }), + context(goodParams), + )) as { ok: boolean; error: string }; + expect(res.ok).toBe(false); + expect(res.error).toContain('AccessDenied'); + expect(JSON.stringify(res)).not.toContain('at '); + }); +}); + +describe('provider routing and validation', () => { + const listLogFiles = vi.fn(async () => ({ files: [], truncated: false })); + + it('defaults to s3 when provider is absent and routes the s3 config', async () => { + const factory = vi.fn(() => ({ listLogFiles })); + const handler = makeHandler(factory); + await handler(event({ startDate: '2026-06-01', endDate: '2026-06-10' }), context(goodParams)); + expect(factory).toHaveBeenCalledWith(expect.objectContaining({ provider: 's3', bucketName: 'b' })); + }); + + it('routes azure config when provider=azure', async () => { + const factory = vi.fn(() => ({ listLogFiles })); + const handler = makeHandler(factory); + const res = await handler( + event({ startDate: '2026-06-01', endDate: '2026-06-10' }), + context({ + provider: 'azure', + azureAccountName: 'acct', + azureContainerName: 'logs', + azureAccountKey: 'a2V5', + }), + ); + expect(res).toEqual({ ok: true, files: [], truncated: false }); + expect(factory).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'azure', azureAccountName: 'acct' }), + ); + }); + + it('reports azure missing params by name', async () => { + const handler = makeHandler(() => ({ listLogFiles })); + const res = (await handler( + event({ startDate: '2026-06-01', endDate: '2026-06-10' }), + context({ provider: 'azure', azureAccountName: 'acct' }), + )) as { ok: boolean; error: string }; + expect(res.ok).toBe(false); + expect(res.error).toContain('azureContainerName'); + }); + + it('validates gcs service-account JSON shape', async () => { + const handler = makeHandler(() => ({ listLogFiles })); + const res = (await handler( + event({ startDate: '2026-06-01', endDate: '2026-06-10' }), + context({ provider: 'gcs', gcsBucketName: 'b', gcsServiceAccountKey: '{"nope":1}' }), + )) as { ok: boolean; error: string }; + expect(res.ok).toBe(false); + expect(res.error).toContain('client_email'); + }); + + it('accepts valid gcs params', async () => { + const factory = vi.fn(() => ({ listLogFiles })); + const handler = makeHandler(factory); + const key = JSON.stringify({ client_email: 'x@y.iam.gserviceaccount.com', private_key: 'PEM' }); + const res = await handler( + event({ startDate: '2026-06-01', endDate: '2026-06-10' }), + context({ provider: 'gcs', gcsBucketName: 'b', gcsServiceAccountKey: key }), + ); + expect(res).toEqual({ ok: true, files: [], truncated: false }); + }); + + it('rejects unknown providers cleanly', async () => { + const handler = makeHandler(() => ({ listLogFiles })); + const res = (await handler( + event({ startDate: '2026-06-01', endDate: '2026-06-10' }), + context({ provider: 'ftp' }), + )) as { ok: boolean; error: string }; + expect(res.ok).toBe(false); + expect(res.error).toContain('Unknown storage provider'); + }); +}); diff --git a/examples/audit-log-viewer/functions/__tests__/azure.test.ts b/examples/audit-log-viewer/functions/__tests__/azure.test.ts new file mode 100644 index 0000000000..367e13cb06 --- /dev/null +++ b/examples/audit-log-viewer/functions/__tests__/azure.test.ts @@ -0,0 +1,175 @@ +// @vitest-environment node +import { createHmac } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { + AZURE_SAS_VERSION, + AzureLogStorage, + azureErrorDetail, + azureMintSas, + azureSasStringToSign, +} from '../lib/storage/azure'; +import { createStorage } from '../lib/storage/factory'; +import type { AzureConfig } from '../lib/storage/types'; + +const cfg: AzureConfig = { + azureAccountName: 'acct', + azureContainerName: 'logs', + azureAccountKey: Buffer.from('super-secret-account-key').toString('base64'), +}; + +const key = (yyyymmdd: string) => `contentful-audit-org1-${yyyymmdd}T040000000Z.json`; + +// Real Azure responses carry a leading UTF-8 BOM — reproduce it in the fakes. +const listXml = (names: string[], nextMarker = '') => `\uFEFF + + ${names + .map( + (n) => `${n}42`, + ) + .join('')} + ${nextMarker} +`; + +describe('azureSasStringToSign', () => { + it('produces the documented 16-field service-SAS string', () => { + const s = azureSasStringToSign({ + permissions: 'r', + resource: 'b', + canonicalizedResource: '/blob/acct/logs/file.json', + expiryIso: '2026-07-04T00:15:00Z', + }); + expect(s).toBe( + 'r\n\n2026-07-04T00:15:00Z\n/blob/acct/logs/file.json\n\n\nhttps\n' + + AZURE_SAS_VERSION + + '\nb\n\n\n\n\n\n\n', + ); + expect(s.split('\n')).toHaveLength(16); + }); +}); + +describe('azureMintSas', () => { + it('signs with HMAC-SHA256 of the base64-decoded key (verified via node:crypto)', async () => { + const input = { + permissions: 'l' as const, + resource: 'c' as const, + canonicalizedResource: '/blob/acct/logs', + expiryIso: '2026-07-04T00:15:00Z', + }; + const sas = new URLSearchParams(await azureMintSas(cfg.azureAccountKey, input)); + expect(sas.get('sv')).toBe(AZURE_SAS_VERSION); + expect(sas.get('sp')).toBe('l'); + expect(sas.get('sr')).toBe('c'); + expect(sas.get('spr')).toBe('https'); + expect(sas.get('se')).toBe('2026-07-04T00:15:00Z'); + const expected = createHmac('sha256', Buffer.from(cfg.azureAccountKey, 'base64')) + .update(azureSasStringToSign(input), 'utf8') + .digest('base64'); + expect(sas.get('sig')).toBe(expected); + }); +}); + +describe('AzureLogStorage.listLogFiles', () => { + const now = () => new Date('2026-07-04T00:00:00.000Z'); + + it('paginates via NextMarker, filters by covered date, returns SAS URLs', async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce({ ok: true, text: async () => listXml([key('20260603')], 'M1') }) + .mockResolvedValueOnce({ ok: true, text: async () => listXml([key('20260605'), 'noise.txt']) }); + const storage = new AzureLogStorage(cfg, { fetchFn: fetchFn as unknown as typeof fetch, now }); + const { files, truncated } = await storage.listLogFiles('2026-06-01', '2026-06-10'); + + expect(fetchFn).toHaveBeenCalledTimes(2); + const firstUrl: string = fetchFn.mock.calls[0][0]; + expect(firstUrl).toContain('https://acct.blob.core.windows.net/logs?restype=container&comp=list'); + expect(firstUrl).toContain('prefix=contentful-audit-'); + const secondUrl: string = fetchFn.mock.calls[1][0]; + expect(secondUrl).toContain('marker=M1'); + + expect(truncated).toBe(false); + expect(files.map((f) => f.coveredDate)).toEqual(['2026-06-04', '2026-06-02']); // newest first + for (const f of files) { + expect(f.url).toContain(`https://acct.blob.core.windows.net/logs/${f.key}?`); + expect(f.url).toContain('sp=r'); + expect(f.url).toContain('sr=b'); + expect(f.url).toContain('se=2026-07-04T00%3A15%3A00Z'); // now + 900s, URL-encoded + expect(f.url).toContain('sig='); + } + }); + + it('honors the configured prefix', async () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: true, text: async () => listXml([]) }); + await new AzureLogStorage({ ...cfg, prefix: 'audit/' }, { fetchFn: fetchFn as unknown as typeof fetch, now }) + .listLogFiles('2026-06-01', '2026-06-10'); + expect(fetchFn.mock.calls[0][0]).toContain(`prefix=${encodeURIComponent('audit/contentful-audit-')}`); + }); + + it('throws a clean error on non-200', async () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 403, text: async () => '' }); + await expect( + new AzureLogStorage(cfg, { fetchFn: fetchFn as unknown as typeof fetch, now }).listLogFiles( + '2026-06-01', + '2026-06-10', + ), + ).rejects.toThrow('Azure list failed: HTTP 403'); + }); + + it('appends the upstream error detail when the body has one', async () => { + const body = + 'AuthenticationFailed' + + 'Signature did not match'; + const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 403, text: async () => body }); + await expect( + new AzureLogStorage(cfg, { fetchFn: fetchFn as unknown as typeof fetch, now }).listLogFiles( + '2026-06-01', + '2026-06-10', + ), + ).rejects.toThrow('Azure list failed: HTTP 403 — AuthenticationFailed: Signature did not match'); + }); +}); + +describe('AzureLogStorage input validation', () => { + const now = () => new Date('2026-07-04T00:00:00.000Z'); + + it('rejects an invalid account name at construction', () => { + expect(() => new AzureLogStorage({ ...cfg, azureAccountName: 'Invalid_Name!' })).toThrow( + 'azureAccountName must be 3-24 lowercase letters/digits', + ); + }); + + it('rejects a non-base64 account key when minting a SAS', async () => { + const fetchFn = vi.fn(); + const storage = new AzureLogStorage( + { ...cfg, azureAccountKey: 'not-base64!!!' }, + { fetchFn: fetchFn as unknown as typeof fetch, now }, + ); + await expect(storage.listLogFiles('2026-06-01', '2026-06-10')).rejects.toThrow( + 'azureAccountKey is not valid base64', + ); + }); +}); + +describe('azureErrorDetail', () => { + it('returns "" for an empty or absent body', () => { + expect(azureErrorDetail('')).toBe(''); + }); + + it('extracts Code and AuthenticationErrorDetail from an Azure XML error body', () => { + const body = + 'AuthenticationFailed' + + 'Signature did not match'; + expect(azureErrorDetail(body)).toBe('AuthenticationFailed: Signature did not match'); + }); + + it('caps the extract at 200 characters', () => { + const long = 'x'.repeat(500); + const body = `AuthenticationFailed${long}`; + expect(azureErrorDetail(body).length).toBeLessThanOrEqual(200); + }); +}); + +describe('factory', () => { + it('routes provider=azure to AzureLogStorage', () => { + expect(createStorage({ provider: 'azure', ...cfg })).toBeInstanceOf(AzureLogStorage); + }); +}); diff --git a/examples/audit-log-viewer/functions/__tests__/filenames.test.ts b/examples/audit-log-viewer/functions/__tests__/filenames.test.ts new file mode 100644 index 0000000000..3b55e4499b --- /dev/null +++ b/examples/audit-log-viewer/functions/__tests__/filenames.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { coveredDateFromKey } from '../lib/filenames'; + +describe('coveredDateFromKey', () => { + it('returns the day before the filename datetime (docs example)', () => { + expect( + coveredDateFromKey('contentful-audit-7BLDDu2FYCNoN4QIWys1BR-20251009T040839978Z.json'), + ).toBe('2025-10-08'); + }); + + it('handles keys under a prefix', () => { + expect(coveredDateFromKey('audit/contentful-audit-org1-20260101T050000000Z.json')).toBe( + '2025-12-31', + ); + }); + + it('accepts .json.gz too', () => { + expect(coveredDateFromKey('contentful-audit-org1-20260302T041000123Z.json.gz')).toBe( + '2026-03-01', + ); + }); + + it('rejects non-matching keys', () => { + expect(coveredDateFromKey('somethingelse.json')).toBeNull(); + expect(coveredDateFromKey('contentful-audit-org1-notadate.json')).toBeNull(); + }); +}); diff --git a/examples/audit-log-viewer/functions/__tests__/gcs.test.ts b/examples/audit-log-viewer/functions/__tests__/gcs.test.ts new file mode 100644 index 0000000000..61f01e2b7d --- /dev/null +++ b/examples/audit-log-viewer/functions/__tests__/gcs.test.ts @@ -0,0 +1,203 @@ +// @vitest-environment node +import { createVerify, generateKeyPairSync } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { createStorage } from '../lib/storage/factory'; +import { + GcsLogStorage, + gcsAccessToken, + gcsCanonicalQuery, + gcsCanonicalRequest, + gcsDatestamps, + gcsErrorDetail, + gcsResourcePath, + gcsSignedUrl, + gcsStringToSign, +} from '../lib/storage/gcs'; +import { sha256Hex } from '../lib/storage/webcrypto'; +import type { GcsConfig } from '../lib/storage/types'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, +}); +const saKey = { client_email: 'reader@proj.iam.gserviceaccount.com', private_key: privateKey }; +const cfg: GcsConfig = { + gcsBucketName: 'my-logs', + gcsServiceAccountKey: JSON.stringify(saKey), +}; +const NOW = new Date('2026-07-04T01:02:03.000Z'); +const key = (yyyymmdd: string) => `contentful-audit-org1-${yyyymmdd}T040000000Z.json`; + +describe('V4 signing pieces (documented formats)', () => { + it('datestamps', () => { + expect(gcsDatestamps(NOW)).toEqual({ date: '20260704', timestamp: '20260704T010203Z' }); + }); + + it('canonical query is alphabetical with encoded credential', () => { + expect(gcsCanonicalQuery(saKey, NOW)).toBe( + 'X-Goog-Algorithm=GOOG4-RSA-SHA256' + + '&X-Goog-Credential=reader%40proj.iam.gserviceaccount.com%2F20260704%2Fauto%2Fstorage%2Fgoog4_request' + + '&X-Goog-Date=20260704T010203Z' + + '&X-Goog-Expires=900' + + '&X-Goog-SignedHeaders=host', + ); + }); + + it('canonical request has the documented 7 lines', () => { + const cr = gcsCanonicalRequest('/my-logs/a%20b.json', 'Q=1'); + expect(cr).toBe('GET\n/my-logs/a%20b.json\nQ=1\nhost:storage.googleapis.com\n\nhost\nUNSIGNED-PAYLOAD'); + }); + + it('string-to-sign has the documented 4 lines', () => { + expect(gcsStringToSign('20260704T010203Z', '20260704', 'deadbeef')).toBe( + 'GOOG4-RSA-SHA256\n20260704T010203Z\n20260704/auto/storage/goog4_request\ndeadbeef', + ); + }); + + it('resource path encodes segments but keeps slashes', () => { + expect(gcsResourcePath('b', 'audit/file name.json')).toBe('/b/audit/file%20name.json'); + }); +}); + +describe('gcsSignedUrl', () => { + it('produces a URL whose signature node:crypto verifies end-to-end', async () => { + const url = new URL(await gcsSignedUrl(saKey, 'my-logs', 'x.json', NOW)); + const sigHex = url.searchParams.get('X-Goog-Signature')!; + url.searchParams.delete('X-Goog-Signature'); + const canonicalQuery = url.search.slice(1); + const { date, timestamp } = gcsDatestamps(NOW); + const hash = await sha256Hex(gcsCanonicalRequest(url.pathname, canonicalQuery)); + const verify = createVerify('RSA-SHA256').update(gcsStringToSign(timestamp, date, hash), 'utf8'); + expect(verify.verify(publicKey, Buffer.from(sigHex, 'hex'))).toBe(true); + }); +}); + +describe('gcsAccessToken', () => { + it('sends an RS256 JWT grant that node:crypto verifies, returns the token', async () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ access_token: 'tok' }) }); + const token = await gcsAccessToken(saKey, fetchFn as unknown as typeof fetch, NOW); + expect(token).toBe('tok'); + const [url, init] = fetchFn.mock.calls[0]; + expect(url).toBe('https://oauth2.googleapis.com/token'); + const assertion = new URLSearchParams(init.body).get('assertion')!; + const [h, c, s] = assertion.split('.'); + const fromUrl = (x: string) => Buffer.from(x.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + expect(JSON.parse(fromUrl(h).toString())).toEqual({ alg: 'RS256', typ: 'JWT' }); + const claims = JSON.parse(fromUrl(c).toString()); + expect(claims.iss).toBe(saKey.client_email); + expect(claims.aud).toBe('https://oauth2.googleapis.com/token'); + expect(claims.exp - claims.iat).toBe(3600); + const verify = createVerify('RSA-SHA256').update(`${h}.${c}`, 'utf8'); + expect(verify.verify(publicKey, fromUrl(s))).toBe(true); + }); + + it('throws cleanly on exchange failure', async () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }); + await expect(gcsAccessToken(saKey, fetchFn as unknown as typeof fetch, NOW)).rejects.toThrow( + 'GCS token exchange failed: HTTP 401', + ); + }); + + it('appends the upstream error_description when the token endpoint returns one', async () => { + const fetchFn = vi + .fn() + .mockResolvedValue({ ok: false, status: 400, text: async () => JSON.stringify({ error_description: 'invalid_grant' }), json: async () => ({ error_description: 'invalid_grant' }) }); + await expect(gcsAccessToken(saKey, fetchFn as unknown as typeof fetch, NOW)).rejects.toThrow( + 'GCS token exchange failed: HTTP 400 — invalid_grant', + ); + }); +}); + +describe('GcsLogStorage.listLogFiles', () => { + const now = () => NOW; + + it('exchanges a token, paginates the JSON API, returns signed URLs', async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: 'tok' }) }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ items: [{ name: key('20260603'), size: '10' }], nextPageToken: 'P2' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ items: [{ name: key('20260605'), size: '20' }, { name: 'noise.txt', size: '1' }] }), + }); + const storage = new GcsLogStorage(cfg, { fetchFn: fetchFn as unknown as typeof fetch, now }); + const { files, truncated } = await storage.listLogFiles('2026-06-01', '2026-06-10'); + + const listUrl1: string = fetchFn.mock.calls[1][0]; + expect(listUrl1).toContain('https://storage.googleapis.com/storage/v1/b/my-logs/o?'); + expect(listUrl1).toContain(`prefix=${encodeURIComponent('contentful-audit-')}`); + expect(fetchFn.mock.calls[1][1].headers.Authorization).toBe('Bearer tok'); + expect(fetchFn.mock.calls[2][0]).toContain('pageToken=P2'); + + expect(truncated).toBe(false); + expect(files.map((f) => f.coveredDate)).toEqual(['2026-06-04', '2026-06-02']); + for (const f of files) { + expect(f.url).toContain(`https://storage.googleapis.com/my-logs/${f.key}?`); + expect(f.url).toContain('X-Goog-Signature='); + } + expect(files[0].size).toBe(20); // numeric coercion of the JSON API's string sizes + }); + + it('throws a clean error on list failure', async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: 'tok' }) }) + .mockResolvedValueOnce({ ok: false, status: 403, json: async () => ({}) }); + await expect( + new GcsLogStorage(cfg, { fetchFn: fetchFn as unknown as typeof fetch, now }).listLogFiles( + '2026-06-01', + '2026-06-10', + ), + ).rejects.toThrow('GCS list failed: HTTP 403'); + }); + + it('appends the upstream error.message when the list API returns one', async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: 'tok' }) }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => JSON.stringify({ error: { message: 'Invalid Credentials' } }), + json: async () => ({ error: { message: 'Invalid Credentials' } }), + }); + await expect( + new GcsLogStorage(cfg, { fetchFn: fetchFn as unknown as typeof fetch, now }).listLogFiles( + '2026-06-01', + '2026-06-10', + ), + ).rejects.toThrow('GCS list failed: HTTP 403 — Invalid Credentials'); + }); +}); + +describe('factory', () => { + it('routes provider=gcs to GcsLogStorage', () => { + expect(createStorage({ provider: 'gcs', ...cfg })).toBeInstanceOf(GcsLogStorage); + }); +}); + +describe('gcsErrorDetail', () => { + it('returns "" for an empty, absent, or unparseable body', () => { + expect(gcsErrorDetail('')).toBe(''); + expect(gcsErrorDetail(undefined)).toBe(''); + expect(gcsErrorDetail({})).toBe(''); + expect(gcsErrorDetail('not json')).toBe(''); + }); + + it('extracts error.message from the JSON API shape', () => { + expect(gcsErrorDetail({ error: { message: 'Invalid Credentials' } })).toBe('Invalid Credentials'); + }); + + it('extracts error_description from the OAuth token endpoint shape', () => { + expect(gcsErrorDetail({ error_description: 'invalid_grant' })).toBe('invalid_grant'); + }); + + it('caps the extract at 200 characters', () => { + const long = 'x'.repeat(500); + expect(gcsErrorDetail({ error: { message: long } }).length).toBeLessThanOrEqual(200); + }); +}); diff --git a/examples/audit-log-viewer/functions/__tests__/s3.test.ts b/examples/audit-log-viewer/functions/__tests__/s3.test.ts new file mode 100644 index 0000000000..f6e3e9bd6b --- /dev/null +++ b/examples/audit-log-viewer/functions/__tests__/s3.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; +import { MAX_FILES, S3LogStorage } from '../lib/storage/s3'; +import type { StorageConfig } from '../lib/storage/types'; + +const cfg: StorageConfig = { + bucketName: 'test-bucket', + region: 'eu-west-1', + awsAccessKeyId: 'AKIATEST', + awsSecretAccessKey: 'secret', +}; + +const key = (yyyymmdd: string) => `contentful-audit-org1-${yyyymmdd}T040000000Z.json`; + +function fakeStorage(pages: Array<{ Contents: { Key: string; Size?: number }[]; NextContinuationToken?: string }>) { + let call = 0; + const send = vi.fn(async () => { + const page = pages[call++]; + return { ...page, IsTruncated: Boolean(page.NextContinuationToken) }; + }); + const presign = vi.fn(async (_s3: unknown, _bucket: string, k: string) => `https://signed/${k}`); + const storage = new S3LogStorage(cfg, { + getClient: async () => ({ send }) as never, + presign, + }); + return { storage, send, presign }; +} + +describe('S3LogStorage.listLogFiles', () => { + it('returns only files whose covered date is inside the range, with presigned URLs', async () => { + const { storage } = fakeStorage([ + { + Contents: [ + { Key: key('20260601'), Size: 10 }, // covers 2026-05-31 → out + { Key: key('20260603'), Size: 20 }, // covers 2026-06-02 → in + { Key: 'unrelated.txt', Size: 1 }, + ], + }, + ]); + const result = await storage.listLogFiles('2026-06-01', '2026-06-10'); + expect(result.files).toEqual([ + { + key: key('20260603'), + size: 20, + coveredDate: '2026-06-02', + url: `https://signed/${key('20260603')}`, + }, + ]); + expect(result.truncated).toBe(false); + }); + + it('paginates through continuation tokens', async () => { + const { storage, send } = fakeStorage([ + { Contents: [{ Key: key('20260603'), Size: 1 }], NextContinuationToken: 't1' }, + { Contents: [{ Key: key('20260604'), Size: 1 }] }, + ]); + const result = await storage.listLogFiles('2026-06-01', '2026-06-10'); + expect(send).toHaveBeenCalledTimes(2); + expect(result.files).toHaveLength(2); + }); + + it('sorts newest first, caps at MAX_FILES and sets truncated', async () => { + const contents = Array.from({ length: MAX_FILES + 5 }, (_, i) => ({ + Key: key(`202601${String((i % 28) + 1).padStart(2, '0')}`), + Size: 1, + })); + const { storage } = fakeStorage([{ Contents: contents }]); + const result = await storage.listLogFiles('2025-12-01', '2026-02-28'); + expect(result.files).toHaveLength(MAX_FILES); + expect(result.truncated).toBe(true); + expect(result.files[0].coveredDate >= result.files[1].coveredDate).toBe(true); + }); + + it('lists with the configured prefix + contentful-audit-', async () => { + const { storage, send } = fakeStorage([{ Contents: [] }]); + await new S3LogStorage({ ...cfg, prefix: 'audit/' }, { + getClient: async () => ({ send }) as never, + presign: async () => 'u', + }).listLogFiles('2026-06-01', '2026-06-10'); + const cmdInput = send.mock.calls[0][0].input; + expect(cmdInput.Prefix).toBe('audit/contentful-audit-'); + expect(cmdInput.Bucket).toBe('test-bucket'); + void storage; + }); +}); diff --git a/examples/audit-log-viewer/functions/__tests__/select.test.ts b/examples/audit-log-viewer/functions/__tests__/select.test.ts new file mode 100644 index 0000000000..25c049142f --- /dev/null +++ b/examples/audit-log-viewer/functions/__tests__/select.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { MAX_FILES, selectLogFiles } from '../lib/storage/select'; + +const key = (yyyymmdd: string) => `contentful-audit-org1-${yyyymmdd}T040000000Z.json`; + +describe('selectLogFiles', () => { + it('keeps only files whose covered date is inside the range', () => { + const { selected, truncated } = selectLogFiles( + [ + { key: key('20260601'), size: 10 }, // covers 2026-05-31 → out + { key: key('20260603'), size: 20 }, // covers 2026-06-02 → in + { key: 'unrelated.txt', size: 1 }, + ], + '2026-06-01', + '2026-06-10', + ); + expect(selected).toEqual([{ key: key('20260603'), size: 20, coveredDate: '2026-06-02' }]); + expect(truncated).toBe(false); + }); + + it('sorts newest first, caps at MAX_FILES and sets truncated', () => { + const objects = Array.from({ length: MAX_FILES + 5 }, (_, i) => ({ + key: key(`202601${String((i % 28) + 1).padStart(2, '0')}`), + size: 1, + })); + const { selected, truncated } = selectLogFiles(objects, '2025-12-01', '2026-02-28'); + expect(selected).toHaveLength(MAX_FILES); + expect(truncated).toBe(true); + expect(selected[0].coveredDate >= selected[1].coveredDate).toBe(true); + }); +}); diff --git a/examples/audit-log-viewer/functions/__tests__/webcrypto.test.ts b/examples/audit-log-viewer/functions/__tests__/webcrypto.test.ts new file mode 100644 index 0000000000..dea5636bc6 --- /dev/null +++ b/examples/audit-log-viewer/functions/__tests__/webcrypto.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment node +import { createHmac, createVerify, generateKeyPairSync, createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + base64Decode, + base64Encode, + base64UrlEncode, + hmacSha256, + pemToPkcs8Bytes, + rsaSha256Sign, + sha256Hex, +} from '../lib/storage/webcrypto'; + +describe('base64 helpers', () => { + it('round-trips bytes', () => { + const bytes = new Uint8Array([0, 1, 254, 255, 100]); + expect(base64Decode(base64Encode(bytes))).toEqual(bytes); + }); + + it('base64url uses -_ and strips padding', () => { + // 0xfb 0xff encodes to "+/8=" in standard base64 + expect(base64UrlEncode(new Uint8Array([0xfb, 0xff]))).toBe('-_8'); + }); +}); + +describe('hmacSha256', () => { + it('matches node:crypto for the same key and message', async () => { + const keyBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const message = 'r\n\n2026-07-04T00:00:00Z\n/blob/acct/container/file.json\n\n\nhttps\n2022-11-02\nb\n\n\n\n\n\n\n'; + const ours = await hmacSha256(keyBytes, message); + const expected = createHmac('sha256', Buffer.from(keyBytes)).update(message, 'utf8').digest(); + expect(Buffer.from(ours).equals(expected)).toBe(true); + }); +}); + +describe('rsaSha256Sign', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + it('produces a signature node:crypto verifies (RSASSA-PKCS1-v1_5)', async () => { + const message = 'GOOG4-RSA-SHA256\n20260704T000000Z\n20260704/auto/storage/goog4_request\nabc'; + const sig = await rsaSha256Sign(privateKey, message); + const verify = createVerify('RSA-SHA256').update(message, 'utf8'); + expect(verify.verify(publicKey, Buffer.from(sig))).toBe(true); + }); + + it('pemToPkcs8Bytes matches node DER output', () => { + const der = pemToPkcs8Bytes(privateKey); + const body = privateKey + .replace(/-----(BEGIN|END) PRIVATE KEY-----/g, '') + .replace(/\s+/g, ''); + expect(Buffer.from(der).equals(Buffer.from(body, 'base64'))).toBe(true); + }); +}); + +describe('sha256Hex', () => { + it('matches node:crypto', async () => { + expect(await sha256Hex('hello')).toBe(createHash('sha256').update('hello').digest('hex')); + }); +}); diff --git a/examples/audit-log-viewer/functions/auditLogBroker.ts b/examples/audit-log-viewer/functions/auditLogBroker.ts new file mode 100644 index 0000000000..130c41f4c9 --- /dev/null +++ b/examples/audit-log-viewer/functions/auditLogBroker.ts @@ -0,0 +1,90 @@ +import type { FunctionEventHandler } from '@contentful/node-apps-toolkit'; +import type { + AppActionRequest, + FunctionEventContext, + FunctionTypeEnum, +} from '@contentful/node-apps-toolkit/lib/requests/typings'; +import { createStorage } from './lib/storage/factory'; +import type { LogStorageProvider, ProviderConfig } from './lib/storage/types'; + +type ActionParams = { startDate: string; endDate: string }; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +const REQUIRED_BY_PROVIDER = { + s3: ['bucketName', 'region', 'awsAccessKeyId', 'awsSecretAccessKey'], + azure: ['azureAccountName', 'azureContainerName', 'azureAccountKey'], + gcs: ['gcsBucketName', 'gcsServiceAccountKey'], +} as const; + +function readConfig(params: Record): ProviderConfig | { configError: string } { + const p = params as Record; + const provider = p.provider || 's3'; + if (provider !== 's3' && provider !== 'azure' && provider !== 'gcs') { + return { configError: `Unknown storage provider "${provider}"` }; + } + for (const key of REQUIRED_BY_PROVIDER[provider]) { + if (typeof p[key] !== 'string' || p[key] === '') { + return { configError: `App is not configured: missing installation parameter "${key}"` }; + } + } + if (provider === 'gcs') { + try { + const parsed = JSON.parse(p.gcsServiceAccountKey!); + if (!parsed.client_email || !parsed.private_key) throw new Error('missing fields'); + } catch { + return { + configError: + 'gcsServiceAccountKey is not valid service-account JSON (expected client_email and private_key)', + }; + } + } + const prefix = p.prefix || undefined; + if (provider === 'azure') { + return { + provider, + azureAccountName: p.azureAccountName!, + azureContainerName: p.azureContainerName!, + azureAccountKey: p.azureAccountKey!, + prefix, + }; + } + if (provider === 'gcs') { + return { provider, gcsBucketName: p.gcsBucketName!, gcsServiceAccountKey: p.gcsServiceAccountKey!, prefix }; + } + return { + provider, + bucketName: p.bucketName!, + region: p.region!, + prefix, + roleArn: p.roleArn || undefined, + externalId: p.externalId || undefined, + awsAccessKeyId: p.awsAccessKeyId!, + awsSecretAccessKey: p.awsSecretAccessKey!, + }; +} + +type StorageFactory = (cfg: ProviderConfig) => LogStorageProvider; + +export const makeHandler = + (storageFactory: StorageFactory = createStorage): FunctionEventHandler => + async (event: AppActionRequest<'Custom', ActionParams>, context: FunctionEventContext) => { + try { + const { startDate, endDate } = (event.body ?? {}) as Partial; + if (!DATE_RE.test(startDate ?? '') || !DATE_RE.test(endDate ?? '')) { + return { ok: false, error: 'startDate and endDate must be YYYY-MM-DD' }; + } + if (startDate! > endDate!) { + return { ok: false, error: 'startDate must not be after endDate' }; + } + const cfg = readConfig(context.appInstallationParameters); + if ('configError' in cfg) return { ok: false, error: cfg.configError }; + const { files, truncated } = await storageFactory(cfg).listLogFiles(startDate!, endDate!); + return { ok: true, files, truncated }; + } catch (e) { + // Message only — no stack, no config echo, so nothing sensitive reaches the browser. + return { ok: false, error: e instanceof Error ? `${e.name}: ${e.message}` : String(e) }; + } + }; + +export const handler = makeHandler(); diff --git a/examples/audit-log-viewer/functions/lib/filenames.ts b/examples/audit-log-viewer/functions/lib/filenames.ts new file mode 100644 index 0000000000..c585d13a87 --- /dev/null +++ b/examples/audit-log-viewer/functions/lib/filenames.ts @@ -0,0 +1,15 @@ +const FILE_RE = /contentful-audit-[A-Za-z0-9_-]+-(\d{4})(\d{2})(\d{2})T\d{9}Z\.json(\.gz)?$/; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Audit log files are named contentful-audit-{orgId}-{YYYYMMDDTHHMMSSsssZ}.json + * and contain the events of the day BEFORE the export datetime. + */ +export function coveredDateFromKey(key: string): string | null { + const m = FILE_RE.exec(key); + if (!m) return null; + const exportedUtc = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + if (Number.isNaN(exportedUtc)) return null; + return new Date(exportedUtc - DAY_MS).toISOString().slice(0, 10); +} diff --git a/examples/audit-log-viewer/functions/lib/storage/azure.ts b/examples/audit-log-viewer/functions/lib/storage/azure.ts new file mode 100644 index 0000000000..0cba9ba194 --- /dev/null +++ b/examples/audit-log-viewer/functions/lib/storage/azure.ts @@ -0,0 +1,167 @@ +import { DOMParser } from '@xmldom/xmldom'; +import { selectLogFiles } from './select'; +import { base64Decode, base64Encode, hmacSha256 } from './webcrypto'; +import type { AzureConfig, ListResult, LogStorageProvider } from './types'; + +export const AZURE_SAS_VERSION = '2022-11-02'; +const URL_TTL_SECONDS = 900; +const ERROR_DETAIL_MAX_LENGTH = 200; + +/** + * Extracts a short, non-sensitive detail from an Azure blob-service error body + * (XML: ......, sometimes with + * for signature mismatches). Tolerates empty/absent + * bodies and returns '' when nothing usable is found. + */ +export function azureErrorDetail(body: string): string { + if (!body) return ''; + const code = /([^<]*)<\/Code>/.exec(body)?.[1]; + const authDetail = /([^<]*)<\/AuthenticationErrorDetail>/.exec(body)?.[1]; + const message = /([^<]*)<\/Message>/.exec(body)?.[1]; + const detail = authDetail ?? message; + const parts = [code, detail].filter((p): p is string => Boolean(p && p.trim())); + return parts.join(': ').slice(0, ERROR_DETAIL_MAX_LENGTH); +} + +export interface AzureSasInput { + permissions: 'r' | 'l'; + resource: 'b' | 'c'; + /** /blob/{account}/{container}[/{blobName}] */ + canonicalizedResource: string; + /** ISO-8601 without milliseconds, e.g. 2026-07-04T00:15:00Z */ + expiryIso: string; +} + +/** + * Service-SAS string-to-sign for the blob service, version 2020-12-06+. + * Sixteen newline-separated fields; we leave start time, identifier, IP, + * snapshot time, encryption scope and the five response-header overrides + * empty. https://learn.microsoft.com/rest/api/storageservices/create-service-sas + */ +export function azureSasStringToSign(i: AzureSasInput): string { + return [ + i.permissions, // signedPermissions (sp) + '', // signedStart (st) + i.expiryIso, // signedExpiry (se) + i.canonicalizedResource, + '', // signedIdentifier + '', // signedIP + 'https', // signedProtocol (spr) + AZURE_SAS_VERSION, // signedVersion (sv) + i.resource, // signedResource (sr) + '', // signedSnapshotTime + '', // signedEncryptionScope + '', // rscc + '', // rscd + '', // rsce + '', // rscl + '', // rsct + ].join('\n'); +} + +/** Returns the SAS query string (sv/spr/se/sr/sp/sig). */ +export async function azureMintSas(accountKeyBase64: string, i: AzureSasInput): Promise { + let keyBytes: Uint8Array; + try { + keyBytes = base64Decode(accountKeyBase64); + } catch { + throw new Error('azureAccountKey is not valid base64'); + } + const sig = await hmacSha256(keyBytes, azureSasStringToSign(i)); + const q = new URLSearchParams({ + sv: AZURE_SAS_VERSION, + spr: 'https', + se: i.expiryIso, + sr: i.resource, + sp: i.permissions, + sig: base64Encode(sig), + }); + return q.toString(); +} + +type Deps = { fetchFn?: typeof fetch; now?: () => Date }; + +export class AzureLogStorage implements LogStorageProvider { + private readonly fetchFn: typeof fetch; + private readonly now: () => Date; + + constructor( + private readonly cfg: AzureConfig, + deps: Deps = {}, + ) { + if (!/^[a-z0-9]{3,24}$/.test(cfg.azureAccountName)) { + throw new Error('azureAccountName must be 3-24 lowercase letters/digits'); + } + this.fetchFn = deps.fetchFn ?? ((input, init) => fetch(input, init)); // wrapped: detached fetch throws Illegal invocation in the workerd runtime + this.now = deps.now ?? (() => new Date()); + } + + private containerUrl(): string { + return `https://${this.cfg.azureAccountName}.blob.core.windows.net/${this.cfg.azureContainerName}`; + } + + private containerResource(): string { + return `/blob/${this.cfg.azureAccountName}/${this.cfg.azureContainerName}`; + } + + private expiryIso(): string { + return new Date(this.now().getTime() + URL_TTL_SECONDS * 1000) + .toISOString() + .replace(/\.\d{3}Z$/, 'Z'); + } + + async listLogFiles(startDate: string, endDate: string): Promise { + const prefix = `${this.cfg.prefix ?? ''}contentful-audit-`; + const expiryIso = this.expiryIso(); + const listSas = await azureMintSas(this.cfg.azureAccountKey, { + permissions: 'l', + resource: 'c', + canonicalizedResource: this.containerResource(), + expiryIso, + }); + + const objects: Array<{ key: string; size: number }> = []; + let marker = ''; + do { + const url = + `${this.containerUrl()}?restype=container&comp=list` + + `&prefix=${encodeURIComponent(prefix)}` + + (marker ? `&marker=${encodeURIComponent(marker)}` : '') + + `&${listSas}`; + const res = await this.fetchFn(url); + if (!res.ok) { + const detail = azureErrorDetail(await res.text()); + throw new Error(`Azure list failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`); + } + // Azure prefixes its XML with a UTF-8 BOM, which xmldom rejects. + const xml = (await res.text()).replace(/^\uFEFF/, ''); + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + const blobs = doc.getElementsByTagName('Blob'); + for (let i = 0; i < blobs.length; i++) { + const blob = blobs.item(i); + const name = blob?.getElementsByTagName('Name').item(0)?.textContent ?? ''; + const size = Number( + blob?.getElementsByTagName('Content-Length').item(0)?.textContent ?? '0', + ); + if (name) objects.push({ key: name, size }); + } + marker = doc.getElementsByTagName('NextMarker').item(0)?.textContent?.trim() ?? ''; + } while (marker); + + const { selected, truncated } = selectLogFiles(objects, startDate, endDate); + const files = await Promise.all( + selected.map(async (m) => ({ + ...m, + url: + `${this.containerUrl()}/${m.key.split('/').map(encodeURIComponent).join('/')}?` + + (await azureMintSas(this.cfg.azureAccountKey, { + permissions: 'r', + resource: 'b', + canonicalizedResource: `${this.containerResource()}/${m.key}`, + expiryIso, + })), + })), + ); + return { files, truncated }; + } +} diff --git a/examples/audit-log-viewer/functions/lib/storage/factory.ts b/examples/audit-log-viewer/functions/lib/storage/factory.ts new file mode 100644 index 0000000000..c0162693ce --- /dev/null +++ b/examples/audit-log-viewer/functions/lib/storage/factory.ts @@ -0,0 +1,16 @@ +import { AzureLogStorage } from './azure'; +import { GcsLogStorage } from './gcs'; +import { S3LogStorage } from './s3'; +import type { LogStorageProvider, ProviderConfig } from './types'; + +/** Single seam through which the handler obtains a provider. */ +export function createStorage(cfg: ProviderConfig): LogStorageProvider { + switch (cfg.provider) { + case 's3': + return new S3LogStorage(cfg); + case 'azure': + return new AzureLogStorage(cfg); + case 'gcs': + return new GcsLogStorage(cfg); + } +} diff --git a/examples/audit-log-viewer/functions/lib/storage/gcs.ts b/examples/audit-log-viewer/functions/lib/storage/gcs.ts new file mode 100644 index 0000000000..fafd5ed9b8 --- /dev/null +++ b/examples/audit-log-viewer/functions/lib/storage/gcs.ts @@ -0,0 +1,183 @@ +import { selectLogFiles } from './select'; +import { base64UrlEncode, rsaSha256Sign, sha256Hex } from './webcrypto'; +import type { GcsConfig, ListResult, LogStorageProvider } from './types'; + +const HOST = 'storage.googleapis.com'; +const TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const SCOPE = 'https://www.googleapis.com/auth/devstorage.read_only'; +const URL_TTL_SECONDS = 900; +const ERROR_DETAIL_MAX_LENGTH = 200; +const encoder = new TextEncoder(); + +/** + * Extracts a short, non-sensitive detail from a GCS/OAuth error body. Accepts + * either an already-parsed body (object) or a raw string, and tolerates + * empty/absent/unparseable bodies, returning '' when nothing usable is found. + * Handles the JSON API's `{ error: { message } }` shape and the token + * endpoint's `{ error_description }` (and OAuth `{ error }`) shape. + */ +export function gcsErrorDetail(body: unknown): string { + let parsed: unknown = body; + if (typeof body === 'string') { + if (!body.trim()) return ''; + try { + parsed = JSON.parse(body); + } catch { + return ''; + } + } + if (!parsed || typeof parsed !== 'object') return ''; + const obj = parsed as Record; + const errorField = obj.error; + let detail: unknown; + if (errorField && typeof errorField === 'object') { + detail = (errorField as Record).message; + } else if (typeof errorField === 'string') { + detail = errorField; + } + detail = detail ?? obj.error_description; + if (typeof detail !== 'string' || !detail.trim()) return ''; + return detail.slice(0, ERROR_DETAIL_MAX_LENGTH); +} + +export interface GcsKey { + client_email: string; + private_key: string; +} + +export function gcsDatestamps(now: Date): { date: string; timestamp: string } { + const iso = now.toISOString(); // 2026-07-04T01:02:03.000Z + const date = iso.slice(0, 10).replace(/-/g, ''); + return { date, timestamp: `${date}T${iso.slice(11, 19).replace(/:/g, '')}Z` }; +} + +/** V4 canonical query (already alphabetical). https://cloud.google.com/storage/docs/access-control/signed-urls */ +export function gcsCanonicalQuery(key: GcsKey, now: Date, expires = URL_TTL_SECONDS): string { + const { date, timestamp } = gcsDatestamps(now); + const credential = `${key.client_email}/${date}/auto/storage/goog4_request`; + return ( + 'X-Goog-Algorithm=GOOG4-RSA-SHA256' + + `&X-Goog-Credential=${encodeURIComponent(credential)}` + + `&X-Goog-Date=${timestamp}` + + `&X-Goog-Expires=${expires}` + + '&X-Goog-SignedHeaders=host' + ); +} + +export function gcsResourcePath(bucket: string, object: string): string { + return `/${bucket}/${object.split('/').map(encodeURIComponent).join('/')}`; +} + +export function gcsCanonicalRequest(path: string, canonicalQuery: string): string { + return ['GET', path, canonicalQuery, `host:${HOST}`, '', 'host', 'UNSIGNED-PAYLOAD'].join('\n'); +} + +export function gcsStringToSign(timestamp: string, date: string, canonicalRequestHashHex: string): string { + return ['GOOG4-RSA-SHA256', timestamp, `${date}/auto/storage/goog4_request`, canonicalRequestHashHex].join('\n'); +} + +const toHex = (bytes: Uint8Array) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); + +export async function gcsSignedUrl(key: GcsKey, bucket: string, object: string, now: Date): Promise { + const { date, timestamp } = gcsDatestamps(now); + const path = gcsResourcePath(bucket, object); + const query = gcsCanonicalQuery(key, now); + const hash = await sha256Hex(gcsCanonicalRequest(path, query)); + const sig = await rsaSha256Sign(key.private_key, gcsStringToSign(timestamp, date, hash)); + return `https://${HOST}${path}?${query}&X-Goog-Signature=${toHex(sig)}`; +} + +export async function gcsAccessToken(key: GcsKey, fetchFn: typeof fetch, now: Date): Promise { + const iat = Math.floor(now.getTime() / 1000); + const header = base64UrlEncode(encoder.encode(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))); + const claims = base64UrlEncode( + encoder.encode( + JSON.stringify({ iss: key.client_email, scope: SCOPE, aud: TOKEN_URL, iat, exp: iat + 3600 }), + ), + ); + const unsigned = `${header}.${claims}`; + const assertion = `${unsigned}.${base64UrlEncode(await rsaSha256Sign(key.private_key, unsigned))}`; + const res = await fetchFn(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: + `grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')}` + + `&assertion=${assertion}`, + }); + if (!res.ok) { + const detail = gcsErrorDetail(await readGcsErrorBody(res)); + throw new Error(`GCS token exchange failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`); + } + const data = (await res.json()) as { access_token?: string }; + if (!data.access_token) throw new Error('GCS token exchange returned no access_token'); + return data.access_token; +} + +/** Reads an error response body defensively: text once, then try JSON. */ +async function readGcsErrorBody(res: Response): Promise { + let text: string; + try { + text = await res.text(); + } catch { + return undefined; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +type Deps = { fetchFn?: typeof fetch; now?: () => Date }; + +export class GcsLogStorage implements LogStorageProvider { + private readonly fetchFn: typeof fetch; + private readonly now: () => Date; + private readonly key: GcsKey; + + constructor( + private readonly cfg: GcsConfig, + deps: Deps = {}, + ) { + this.fetchFn = deps.fetchFn ?? ((input, init) => fetch(input, init)); // wrapped: detached fetch throws Illegal invocation in the workerd runtime + this.now = deps.now ?? (() => new Date()); + this.key = JSON.parse(cfg.gcsServiceAccountKey) as GcsKey; // shape pre-validated by readConfig + } + + async listLogFiles(startDate: string, endDate: string): Promise { + const now = this.now(); + const token = await gcsAccessToken(this.key, this.fetchFn, now); + const prefix = `${this.cfg.prefix ?? ''}contentful-audit-`; + + const objects: Array<{ key: string; size: number }> = []; + let pageToken = ''; + do { + const url = + `https://${HOST}/storage/v1/b/${encodeURIComponent(this.cfg.gcsBucketName)}/o` + + `?prefix=${encodeURIComponent(prefix)}&fields=${encodeURIComponent('items(name,size),nextPageToken')}` + + (pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ''); + const res = await this.fetchFn(url, { headers: { Authorization: `Bearer ${token}` } }); + if (!res.ok) { + const detail = gcsErrorDetail(await readGcsErrorBody(res)); + throw new Error(`GCS list failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`); + } + const data = (await res.json()) as { + items?: Array<{ name: string; size?: string | number }>; + nextPageToken?: string; + }; + for (const item of data.items ?? []) { + if (item.name) objects.push({ key: item.name, size: Number(item.size ?? 0) }); + } + pageToken = data.nextPageToken ?? ''; + } while (pageToken); + + const { selected, truncated } = selectLogFiles(objects, startDate, endDate); + const files = await Promise.all( + selected.map(async (m) => ({ + ...m, + url: await gcsSignedUrl(this.key, this.cfg.gcsBucketName, m.key, now), + })), + ); + return { files, truncated }; + } +} diff --git a/examples/audit-log-viewer/functions/lib/storage/s3.ts b/examples/audit-log-viewer/functions/lib/storage/s3.ts new file mode 100644 index 0000000000..72badc2cc2 --- /dev/null +++ b/examples/audit-log-viewer/functions/lib/storage/s3.ts @@ -0,0 +1,111 @@ +import { DOMParser, Node as XmlNode } from '@xmldom/xmldom'; + +// The Contentful Functions runtime has no DOM globals; the AWS SDK's browser +// build needs DOMParser and Node (node-type constants) to deserialize S3's +// XML responses. +const g = globalThis as { DOMParser?: unknown; Node?: unknown }; +g.DOMParser ??= DOMParser; +g.Node ??= XmlNode; + +import { GetObjectCommand, ListObjectsV2Command, S3Client } from '@aws-sdk/client-s3'; +import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { FetchHttpHandler } from '@smithy/fetch-http-handler'; +import { MAX_FILES, selectLogFiles } from './select'; +import type { ListResult, LogStorageProvider, StorageConfig } from './types'; + +export { MAX_FILES }; +const URL_TTL_SECONDS = 900; + +/** + * Contentful Functions do not support node:http/https — every client must use + * the fetch-based handler. forcePathStyle keeps S3 hostnames bucket-independent + * so the manifest allowNetworks list stays static. + */ +export async function createS3Client(cfg: StorageConfig): Promise { + let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } = { + accessKeyId: cfg.awsAccessKeyId, + secretAccessKey: cfg.awsSecretAccessKey, + }; + if (cfg.roleArn) { + const sts = new STSClient({ + region: cfg.region, + credentials, + requestHandler: new FetchHttpHandler(), + }); + const assumed = await sts.send( + new AssumeRoleCommand({ + RoleArn: cfg.roleArn, + RoleSessionName: 'contentful-audit-log-viewer', + ExternalId: cfg.externalId || undefined, + DurationSeconds: 3600, + }), + ); + if (!assumed.Credentials?.AccessKeyId || !assumed.Credentials.SecretAccessKey) { + throw new Error('STS AssumeRole returned no credentials'); + } + credentials = { + accessKeyId: assumed.Credentials.AccessKeyId, + secretAccessKey: assumed.Credentials.SecretAccessKey, + sessionToken: assumed.Credentials.SessionToken, + }; + } + return new S3Client({ + region: cfg.region, + credentials, + requestHandler: new FetchHttpHandler(), + forcePathStyle: true, + }); +} + +type Deps = { + getClient?: (cfg: StorageConfig) => Promise; + presign?: (s3: S3Client, bucket: string, key: string) => Promise; +}; + +const defaultPresign = (s3: S3Client, bucket: string, key: string) => + getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: key }), { + expiresIn: URL_TTL_SECONDS, + }); + +export class S3LogStorage implements LogStorageProvider { + private readonly getClient: NonNullable; + private readonly presign: NonNullable; + + constructor( + private readonly cfg: StorageConfig, + deps: Deps = {}, + ) { + this.getClient = deps.getClient ?? createS3Client; + this.presign = deps.presign ?? defaultPresign; + } + + async listLogFiles(startDate: string, endDate: string): Promise { + const s3 = await this.getClient(this.cfg); + const prefix = `${this.cfg.prefix ?? ''}contentful-audit-`; + const objects: Array<{ key: string; size: number }> = []; + let token: string | undefined; + do { + const page = await s3.send( + new ListObjectsV2Command({ + Bucket: this.cfg.bucketName, + Prefix: prefix, + ContinuationToken: token, + }), + ); + for (const obj of page.Contents ?? []) { + if (obj.Key) objects.push({ key: obj.Key, size: obj.Size ?? 0 }); + } + token = page.IsTruncated ? page.NextContinuationToken : undefined; + } while (token); + + const { selected, truncated } = selectLogFiles(objects, startDate, endDate); + const files = await Promise.all( + selected.map(async (m) => ({ + ...m, + url: await this.presign(s3, this.cfg.bucketName, m.key), + })), + ); + return { files, truncated }; + } +} diff --git a/examples/audit-log-viewer/functions/lib/storage/select.ts b/examples/audit-log-viewer/functions/lib/storage/select.ts new file mode 100644 index 0000000000..ed6d944c0e --- /dev/null +++ b/examples/audit-log-viewer/functions/lib/storage/select.ts @@ -0,0 +1,25 @@ +import { coveredDateFromKey } from '../filenames'; +import type { LogFileRef } from './types'; + +export const MAX_FILES = 120; + +/** + * Provider-independent selection: keep audit files whose covered date falls + * inside [startDate, endDate], newest first, capped at MAX_FILES. + */ +export function selectLogFiles( + objects: Array<{ key: string; size: number }>, + startDate: string, + endDate: string, +): { selected: Array>; truncated: boolean } { + const matches: Array> = []; + for (const obj of objects) { + const coveredDate = coveredDateFromKey(obj.key); + if (coveredDate && coveredDate >= startDate && coveredDate <= endDate) { + matches.push({ key: obj.key, size: obj.size, coveredDate }); + } + } + matches.sort((a, b) => b.coveredDate.localeCompare(a.coveredDate)); + const truncated = matches.length > MAX_FILES; + return { selected: matches.slice(0, MAX_FILES), truncated }; +} diff --git a/examples/audit-log-viewer/functions/lib/storage/types.ts b/examples/audit-log-viewer/functions/lib/storage/types.ts new file mode 100644 index 0000000000..682d5c0eaa --- /dev/null +++ b/examples/audit-log-viewer/functions/lib/storage/types.ts @@ -0,0 +1,45 @@ +export interface LogFileRef { + key: string; + url: string; + size: number; + /** YYYY-MM-DD day the file's events belong to (filename date minus one day) */ + coveredDate: string; +} + +export interface ListResult { + files: LogFileRef[]; + truncated: boolean; +} + +/** Seam for a future second storage provider (Azure/GCS) — S3 is the only impl for now. */ +export interface LogStorageProvider { + listLogFiles(startDate: string, endDate: string): Promise; +} + +export interface StorageConfig { + bucketName: string; + region: string; + prefix?: string; + roleArn?: string; + externalId?: string; + awsAccessKeyId: string; + awsSecretAccessKey: string; +} + +export interface AzureConfig { + azureAccountName: string; + azureContainerName: string; + prefix?: string; + azureAccountKey: string; // base64 account key — Secret installation parameter +} + +export interface GcsConfig { + gcsBucketName: string; + prefix?: string; + gcsServiceAccountKey: string; // full service-account JSON — Secret installation parameter +} + +export type ProviderConfig = + | ({ provider: 's3' } & StorageConfig) + | ({ provider: 'azure' } & AzureConfig) + | ({ provider: 'gcs' } & GcsConfig); diff --git a/examples/audit-log-viewer/functions/lib/storage/webcrypto.ts b/examples/audit-log-viewer/functions/lib/storage/webcrypto.ts new file mode 100644 index 0000000000..479a032442 --- /dev/null +++ b/examples/audit-log-viewer/functions/lib/storage/webcrypto.ts @@ -0,0 +1,58 @@ +/** + * Minimal WebCrypto signing helpers shared by the Azure and GCS providers. + * globalThis.crypto.subtle exists in the Contentful Functions runtime and in + * Node 20 (vitest). atob/btoa exist in both. + */ + +export function base64Decode(s: string): Uint8Array { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +export function base64Encode(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin); +} + +export function base64UrlEncode(bytes: Uint8Array): string { + return base64Encode(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +export function pemToPkcs8Bytes(pem: string): Uint8Array { + const body = pem.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, '').replace(/\s+/g, ''); + return base64Decode(body); +} + +const encoder = new TextEncoder(); + +export async function hmacSha256(keyBytes: Uint8Array, message: string): Promise { + const key = await crypto.subtle.importKey( + 'raw', + keyBytes as unknown as ArrayBuffer, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(message)); + return new Uint8Array(sig); +} + +export async function rsaSha256Sign(pkcs8Pem: string, message: string): Promise { + const key = await crypto.subtle.importKey( + 'pkcs8', + pemToPkcs8Bytes(pkcs8Pem) as unknown as ArrayBuffer, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + false, + ['sign'], + ); + const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, encoder.encode(message)); + return new Uint8Array(sig); +} + +export async function sha256Hex(message: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(message)); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/examples/audit-log-viewer/functions/tsconfig.json b/examples/audit-log-viewer/functions/tsconfig.json new file mode 100644 index 0000000000..7e359b92fc --- /dev/null +++ b/examples/audit-log-viewer/functions/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@tsconfig/recommended/tsconfig.json", + "compilerOptions": {}, + "include": ["./**/*.ts"], +} diff --git a/examples/audit-log-viewer/index.html b/examples/audit-log-viewer/index.html new file mode 100644 index 0000000000..cf65f5e579 --- /dev/null +++ b/examples/audit-log-viewer/index.html @@ -0,0 +1,20 @@ + + + + + + + + +
+ + + + diff --git a/examples/audit-log-viewer/package.json b/examples/audit-log-viewer/package.json new file mode 100644 index 0000000000..ca1aaccdeb --- /dev/null +++ b/examples/audit-log-viewer/package.json @@ -0,0 +1,71 @@ +{ + "name": "audit-log-viewer-example", + "version": "0.1.0", + "private": true, + "dependencies": { + "@aws-sdk/client-s3": "^3.1078.0", + "@aws-sdk/client-sts": "^3.1078.0", + "@aws-sdk/s3-request-presigner": "^3.1078.0", + "@contentful/app-sdk": "^4.29.1", + "@contentful/f36-components": "4.81.1", + "@contentful/f36-tokens": "^6.2.1", + "@contentful/react-apps-toolkit": "1.2.16", + "@smithy/fetch-http-handler": "^5.6.2", + "@xmldom/xmldom": "^0.9.10", + "emotion": "10.0.27", + "react": "18.3.1", + "react-dom": "18.3.1", + "recharts": "^3.9.1" + }, + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build && npm run build:functions", + "preview": "vite preview", + "test": "vitest run", + "test:ci": "vitest run", + "create-app-definition": "contentful-app-scripts create-app-definition", + "add-locations": "contentful-app-scripts add-locations", + "upload": "contentful-app-scripts upload --bundle-dir ./build", + "upload-ci": "contentful-app-scripts upload --ci --bundle-dir ./build --organization-id $CONTENTFUL_ORG_ID --definition-id $CONTENTFUL_APP_DEF_ID --token $CONTENTFUL_ACCESS_TOKEN", + "build:functions": "contentful-app-scripts build-functions --ci", + "configure-app": "node scripts/configure-app.mjs", + "invoke": "node scripts/invoke-action.mjs", + "install-app": "node scripts/install-app.mjs", + "set-app-icon": "node scripts/set-app-icon.mjs" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@contentful/app-scripts": "^2.3.0", + "@contentful/node-apps-toolkit": "^3.11.1", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^14.3.1", + "@tsconfig/recommended": "1.0.8", + "@types/node": "^22.13.5", + "@types/react": "18.3.13", + "@types/react-dom": "18.3.1", + "@vitejs/plugin-react": "^4.0.3", + "contentful-management": "^12.8.0", + "cross-env": "7.0.3", + "dotenv": "^17.4.2", + "jsdom": "^26.0.0", + "typescript": "4.9.5", + "vite": "^6.2.2", + "vitest": "^3.0.9" + }, + "homepage": "." +} diff --git a/examples/audit-log-viewer/scripts/configure-app.mjs b/examples/audit-log-viewer/scripts/configure-app.mjs new file mode 100644 index 0000000000..b764f70b23 --- /dev/null +++ b/examples/audit-log-viewer/scripts/configure-app.mjs @@ -0,0 +1,56 @@ +import 'dotenv/config'; +import { createClient } from 'contentful-management'; + +const { CONTENTFUL_ACCESS_TOKEN, CONTENTFUL_ORG_ID, CONTENTFUL_APP_DEF_ID } = process.env; +if (!CONTENTFUL_ACCESS_TOKEN || !CONTENTFUL_ORG_ID || !CONTENTFUL_APP_DEF_ID) { + console.error('Set CONTENTFUL_ACCESS_TOKEN, CONTENTFUL_ORG_ID, CONTENTFUL_APP_DEF_ID in .env'); + process.exit(1); +} +const client = createClient({ accessToken: CONTENTFUL_ACCESS_TOKEN }, { type: 'plain' }); +const ids = { organizationId: CONTENTFUL_ORG_ID, appDefinitionId: CONTENTFUL_APP_DEF_ID }; + +const def = await client.appDefinition.get(ids); +def.parameters = { + installation: [ + { id: 'provider', name: 'Storage provider (s3 | azure | gcs; empty = s3)', type: 'Symbol' }, + { id: 'prefix', name: 'Object key prefix (optional, must end with /)', type: 'Symbol' }, + // Amazon S3 + { id: 'bucketName', name: 'S3 bucket name', type: 'Symbol' }, + { id: 'region', name: 'AWS region (e.g. eu-west-1)', type: 'Symbol' }, + { id: 'roleArn', name: 'IAM role ARN to assume (optional)', type: 'Symbol' }, + { id: 'externalId', name: 'STS external ID (optional)', type: 'Symbol' }, + { id: 'awsAccessKeyId', name: 'AWS access key ID', type: 'Secret' }, + { id: 'awsSecretAccessKey', name: 'AWS secret access key', type: 'Secret' }, + // Azure Blob Storage + { id: 'azureAccountName', name: 'Azure storage account name', type: 'Symbol' }, + { id: 'azureContainerName', name: 'Azure container name', type: 'Symbol' }, + { id: 'azureAccountKey', name: 'Azure storage account key', type: 'Secret' }, + // Google Cloud Storage + { id: 'gcsBucketName', name: 'GCS bucket name', type: 'Symbol' }, + { id: 'gcsServiceAccountKey', name: 'GCS service account key (JSON)', type: 'Secret' }, + ], +}; +await client.appDefinition.update(ids, def); +console.log('✔ installation parameters updated'); + +const ACTION = { + name: 'listAuditLogFiles', + description: + 'Lists audit log files for a date range and returns short-lived pre-signed GET URLs', + type: 'function-invocation', + function: { sys: { type: 'Link', linkType: 'Function', id: 'auditLogBroker' } }, + category: 'Custom', + parameters: [ + { id: 'startDate', name: 'Start date (YYYY-MM-DD)', type: 'Symbol' }, + { id: 'endDate', name: 'End date (YYYY-MM-DD)', type: 'Symbol' }, + ], +}; +const existing = await client.appAction.getMany(ids); +const found = existing.items.find((a) => a.name === ACTION.name); +if (found) { + await client.appAction.update({ ...ids, appActionId: found.sys.id }, ACTION); + console.log(`✔ app action updated: ${found.sys.id}`); +} else { + const created = await client.appAction.create(ids, ACTION); + console.log(`✔ app action created: ${created.sys.id}`); +} diff --git a/examples/audit-log-viewer/scripts/install-app.mjs b/examples/audit-log-viewer/scripts/install-app.mjs new file mode 100644 index 0000000000..91889687f9 --- /dev/null +++ b/examples/audit-log-viewer/scripts/install-app.mjs @@ -0,0 +1,61 @@ +import 'dotenv/config'; + +// Dev helper: (re-)installs the app into the test space with parameters from +// .env. Needed after every bundle upload — activation wipes the installation +// parameters (see docs/superpowers/plans/gate-result.md). +const e = process.env; +const provider = (e.PROVIDER || 's3').toLowerCase(); + +const contentfulRequired = ['CONTENTFUL_ACCESS_TOKEN', 'CONTENTFUL_SPACE_ID', 'CONTENTFUL_APP_DEF_ID']; +const providerRequired = { + s3: ['AWS_BUCKET_NAME', 'AWS_REGION', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'], + azure: ['AZURE_ACCOUNT_NAME', 'AZURE_CONTAINER_NAME', 'AZURE_ACCOUNT_KEY'], + gcs: ['GCS_BUCKET_NAME', 'GCS_SERVICE_ACCOUNT_KEY_FILE'], +}; +if (!providerRequired[provider]) { + console.error(`Unknown PROVIDER "${provider}" (use s3, azure or gcs)`); + process.exit(1); +} +const missing = [...contentfulRequired, ...providerRequired[provider]].filter((k) => !e[k]); +if (missing.length) { + console.error(`Set ${missing.join(', ')} in .env`); + process.exit(1); +} + +const params = { provider }; +if (e.AWS_PREFIX || e.STORAGE_PREFIX) params.prefix = e.STORAGE_PREFIX || e.AWS_PREFIX; +if (provider === 's3') { + params.bucketName = e.AWS_BUCKET_NAME; + params.region = e.AWS_REGION; + params.awsAccessKeyId = e.AWS_ACCESS_KEY_ID; + params.awsSecretAccessKey = e.AWS_SECRET_ACCESS_KEY; + if (e.AWS_ROLE_ARN) params.roleArn = e.AWS_ROLE_ARN; + if (e.AWS_EXTERNAL_ID) params.externalId = e.AWS_EXTERNAL_ID; +} else if (provider === 'azure') { + params.azureAccountName = e.AZURE_ACCOUNT_NAME; + params.azureContainerName = e.AZURE_CONTAINER_NAME; + params.azureAccountKey = e.AZURE_ACCOUNT_KEY; +} else { + const { readFileSync } = await import('node:fs'); + params.gcsBucketName = e.GCS_BUCKET_NAME; + params.gcsServiceAccountKey = readFileSync(e.GCS_SERVICE_ACCOUNT_KEY_FILE, 'utf8'); +} + +const res = await fetch( + `https://api.contentful.com/spaces/${e.CONTENTFUL_SPACE_ID}/environments/${ + e.CONTENTFUL_ENVIRONMENT_ID || 'master' + }/app_installations/${e.CONTENTFUL_APP_DEF_ID}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${e.CONTENTFUL_ACCESS_TOKEN}`, + 'Content-Type': 'application/vnd.contentful.management.v1+json', + }, + body: JSON.stringify({ parameters: params }), + }, +); +if (!res.ok) { + console.error(`Install failed: ${res.status} ${(await res.text()).slice(0, 300)}`); + process.exit(1); +} +console.log('✔ app installed with parameters from .env'); diff --git a/examples/audit-log-viewer/scripts/invoke-action.mjs b/examples/audit-log-viewer/scripts/invoke-action.mjs new file mode 100644 index 0000000000..acee6ebedd --- /dev/null +++ b/examples/audit-log-viewer/scripts/invoke-action.mjs @@ -0,0 +1,34 @@ +import 'dotenv/config'; +import { createClient } from 'contentful-management'; + +const { + CONTENTFUL_ACCESS_TOKEN, + CONTENTFUL_SPACE_ID, + CONTENTFUL_ENVIRONMENT_ID = 'master', + CONTENTFUL_APP_DEF_ID, +} = process.env; +if (!CONTENTFUL_ACCESS_TOKEN || !CONTENTFUL_SPACE_ID || !CONTENTFUL_APP_DEF_ID) { + console.error('Set CONTENTFUL_ACCESS_TOKEN, CONTENTFUL_SPACE_ID, CONTENTFUL_APP_DEF_ID in .env'); + process.exit(1); +} +const [startDate = '2026-01-01', endDate = '2026-12-31'] = process.argv.slice(2); +const client = createClient({ accessToken: CONTENTFUL_ACCESS_TOKEN }, { type: 'plain' }); +const env = { spaceId: CONTENTFUL_SPACE_ID, environmentId: CONTENTFUL_ENVIRONMENT_ID }; + +const actions = await client.appAction.getManyForEnvironment(env); +const action = actions.items.find( + (a) => a.name === 'listAuditLogFiles' && a.sys.appDefinition.sys.id === CONTENTFUL_APP_DEF_ID, +); +if (!action) { + console.error('App action "listAuditLogFiles" not found — run npm run configure-app first'); + process.exit(1); +} +const call = await client.appActionCall.createWithResult( + { ...env, appDefinitionId: CONTENTFUL_APP_DEF_ID, appActionId: action.sys.id, retries: 15 }, + { parameters: { startDate, endDate } }, +); +if (call.sys.status !== 'succeeded') { + console.error(JSON.stringify(call.sys, null, 2)); + process.exit(1); +} +console.log(JSON.stringify(call.sys.result, null, 2)); diff --git a/examples/audit-log-viewer/scripts/set-app-icon.mjs b/examples/audit-log-viewer/scripts/set-app-icon.mjs new file mode 100644 index 0000000000..4ba57e6371 --- /dev/null +++ b/examples/audit-log-viewer/scripts/set-app-icon.mjs @@ -0,0 +1,23 @@ +import 'dotenv/config'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createClient } from 'contentful-management'; + +// Sets the app definition's icon (shown in the Apps list) from assets/logo.png. +// The AppDetails API requires a data URI — raw base64 is rejected with +// "Icon is not in base64 format". A ~100px PNG keeps the payload small. +const { CONTENTFUL_ACCESS_TOKEN, CONTENTFUL_ORG_ID, CONTENTFUL_APP_DEF_ID } = process.env; +if (!CONTENTFUL_ACCESS_TOKEN || !CONTENTFUL_ORG_ID || !CONTENTFUL_APP_DEF_ID) { + console.error('Set CONTENTFUL_ACCESS_TOKEN, CONTENTFUL_ORG_ID, CONTENTFUL_APP_DEF_ID in .env'); + process.exit(1); +} + +const small = '/tmp/audit-log-viewer-icon-100.png'; +execFileSync('sips', ['-z', '100', '100', 'assets/logo.png', '--out', small], { stdio: 'ignore' }); + +const client = createClient({ accessToken: CONTENTFUL_ACCESS_TOKEN }, { type: 'plain' }); +const details = await client.appDetails.upsert( + { organizationId: CONTENTFUL_ORG_ID, appDefinitionId: CONTENTFUL_APP_DEF_ID }, + { icon: { value: `data:image/png;base64,${readFileSync(small).toString('base64')}`, type: 'base64' } }, +); +console.log('✔ app icon set:', Boolean(details.icon)); diff --git a/examples/audit-log-viewer/src/App.tsx b/examples/audit-log-viewer/src/App.tsx new file mode 100644 index 0000000000..22b53b3678 --- /dev/null +++ b/examples/audit-log-viewer/src/App.tsx @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; +import { locations } from '@contentful/app-sdk'; +import ConfigScreen from './locations/ConfigScreen'; +import Dialog from './locations/Dialog'; +import Page from './locations/Page'; +import { useSDK } from '@contentful/react-apps-toolkit'; + +const ComponentLocationSettings = { + [locations.LOCATION_APP_CONFIG]: ConfigScreen, + [locations.LOCATION_DIALOG]: Dialog, + [locations.LOCATION_PAGE]: Page, +}; + +const App = () => { + const sdk = useSDK(); + + const Component = useMemo(() => { + for (const [location, component] of Object.entries(ComponentLocationSettings)) { + if (sdk.location.is(location)) { + return component; + } + } + }, [sdk.location]); + + return Component ? : null; +}; + +export default App; diff --git a/examples/audit-log-viewer/src/components/ChartsPanel.test.tsx b/examples/audit-log-viewer/src/components/ChartsPanel.test.tsx new file mode 100644 index 0000000000..0e0c6ce32e --- /dev/null +++ b/examples/audit-log-viewer/src/components/ChartsPanel.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { AuditEvent } from '../lib/events'; +import { ChartsPanel } from './ChartsPanel'; + +const ev = (activity: string, actorName: string, timeIso: string): AuditEvent => ({ + time: Date.parse(timeIso), + timeIso, + activity, + actorType: 'User', + actorId: actorName, + actorName, + entityType: 'Entry', + entityId: 'e', + spaceId: 's', + spaceName: 's', + path: '/p', + method: 'PUT', + status: 200, + raw: {}, +}); + +describe('ChartsPanel', () => { + it('renders three chart headings', () => { + const { getByText } = render( + , + ); + expect(getByText('Events over time')).toBeInTheDocument(); + expect(getByText('Top actors')).toBeInTheDocument(); + expect(getByText('Actions')).toBeInTheDocument(); + }); +}); diff --git a/examples/audit-log-viewer/src/components/ChartsPanel.tsx b/examples/audit-log-viewer/src/components/ChartsPanel.tsx new file mode 100644 index 0000000000..deb1550e35 --- /dev/null +++ b/examples/audit-log-viewer/src/components/ChartsPanel.tsx @@ -0,0 +1,152 @@ +import { Box, Flex, Subheading } from '@contentful/f36-components'; +import tokens from '@contentful/f36-tokens'; +import { useMemo, type ReactNode } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + type TooltipContentProps, +} from 'recharts'; +import { eventsPerDay, topBy, type AuditEvent } from '../lib/events'; + +const t = tokens as unknown as Record; +const LINE = t.colorDatavizCategorical1Default; +const BAR_A = t.colorDatavizCategorical1Default; +const BAR_B = t.colorDatavizCategorical2Default; + +const GRIDLINE = t.colorDatavizChartGridline; +const AXIS_LINE = tokens.colorElementMid; +const AXIS_TICK = t.colorDatavizAxisLabels; +const SURFACE = tokens.colorElementLightest; +const INK_PRIMARY = tokens.colorBlack; +const CURSOR_WASH = tokens.colorElementLight; + +const tooltipContentStyle: React.CSSProperties = { + background: SURFACE, + border: `1px solid ${tokens.colorElementMid}`, + borderRadius: tokens.borderRadiusMedium, + padding: `${tokens.spacingXs} ${tokens.spacingS}`, + boxShadow: tokens.boxShadowDefault, +}; + +/** + * A minimal custom tooltip that follows the dataviz skill's interaction + * guidance: values lead (bold, high-contrast) and the label follows + * (secondary, muted); the series is keyed with a short line stroke rather + * than a filled box. + */ +const makeTooltip = + (color: string) => + ({ active, payload, label }: TooltipContentProps) => { + if (!active || !payload?.length) return null; + return ( + + + + {payload[0]?.value} + + {label} + + ); + }; + +const Card = ({ title, children }: { title: string; children: ReactNode }) => ( + + {title} + {children} + +); + +export const ChartsPanel = ({ events }: { events: AuditEvent[] }) => { + // Memoized: events can be thousands and the parent re-renders on filter typing. + const perDay = useMemo(() => eventsPerDay(events), [events]); + const topActors = useMemo(() => topBy(events, (e) => e.actorName, 8), [events]); + const topActions = useMemo(() => topBy(events, (e) => e.activity, 8), [events]); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/examples/audit-log-viewer/src/components/EventsTable.test.tsx b/examples/audit-log-viewer/src/components/EventsTable.test.tsx new file mode 100644 index 0000000000..7baf97019c --- /dev/null +++ b/examples/audit-log-viewer/src/components/EventsTable.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { AuditEvent } from '../lib/events'; +import { EventsTable } from './EventsTable'; + +const ev = (over: Partial): AuditEvent => ({ + time: Date.parse('2026-06-29T10:15:42.123Z'), + timeIso: '2026-06-29T10:15:42.123Z', + activity: 'Update', + actorType: 'User', + actorId: 'u1', + actorName: 'Jane Smith', + entityType: 'Entry', + entityId: 'e1', + spaceId: 'sp1', + spaceName: 'sp1', + path: '/spaces/sp1/entries/e1', + method: 'PUT', + status: 200, + raw: {}, + ...over, +}); + +describe('EventsTable', () => { + it('renders one row per event with actor and action', () => { + render(); + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('paginates past 25 rows', () => { + const events = Array.from({ length: 30 }, (_, i) => ev({ entityId: `e${i}` })); + render(); + expect(screen.getAllByRole('row')).toHaveLength(26); // header + 25 + }); + + it('clamps the page when events shrink below the current offset', () => { + const many = Array.from({ length: 30 }, (_, i) => ev({ entityId: `e${i}` })); + const { rerender } = render(); + + // Advance to page 2 (0-indexed page 1) via the Forma 36 Pagination next-page control. + fireEvent.click(screen.getByRole('button', { name: 'To next page' })); + + // Page 2 shows the remaining 5 events (e25..e29). + expect(screen.getByText(/e25/)).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(6); // header + 5 remaining rows + + // Shrinking events below the current page's offset must clamp back into range. + rerender(); + expect(screen.getAllByRole('row')).toHaveLength(4); // header + 3 visible rows + }); +}); diff --git a/examples/audit-log-viewer/src/components/EventsTable.tsx b/examples/audit-log-viewer/src/components/EventsTable.tsx new file mode 100644 index 0000000000..c0da80233e --- /dev/null +++ b/examples/audit-log-viewer/src/components/EventsTable.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import { Badge, Box, Button, Flex, Select, Table, Text } from '@contentful/f36-components'; +import { ChevronLeftIcon, ChevronRightIcon } from '@contentful/f36-icons'; +import tokens from '@contentful/f36-tokens'; +import type { AuditEvent } from '../lib/events'; + + +const ACTIVITY_VARIANT: Record = { + Create: 'positive', + Publish: 'positive', + Update: 'primary', + Unpublish: 'warning', + Delete: 'negative', + Archive: 'negative', +}; + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +export const EventsTable = ({ events }: { events: AuditEvent[] }) => { + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(25); + // Clamp: filtering can shrink events below the current page's offset. + const maxPage = Math.max(0, Math.ceil(events.length / pageSize) - 1); + const activePage = Math.min(page, maxPage); + const pageEvents = events.slice(activePage * pageSize, (activePage + 1) * pageSize); + const firstItem = activePage * pageSize + 1; + const lastItem = Math.min((activePage + 1) * pageSize, events.length); + return ( + <> + + + + Time (UTC) + Action + Actor + Entity + Space + Request + + + + {pageEvents.map((e, i) => ( + + + + {e.timeIso.replace('T', ' ').slice(0, 19)} + + + + {e.activity} + + + {e.actorName} + {e.actorType === 'App' ? ' (app)' : ''} + + + {e.entityType} + {e.entityId ? ` · ${e.entityId}` : ''} + + {e.spaceName || '—'} + + + {e.method} {e.path} + + {e.status ? ` → ${e.status}` : ''} + + + ))} + +
+ {events.length > 0 && ( + + + Show + + + + + {firstItem} – {lastItem} of {events.length} + + + + + + )} + + ); +}; diff --git a/examples/audit-log-viewer/src/components/LocalhostWarning.tsx b/examples/audit-log-viewer/src/components/LocalhostWarning.tsx new file mode 100644 index 0000000000..9702d6ea7e --- /dev/null +++ b/examples/audit-log-viewer/src/components/LocalhostWarning.tsx @@ -0,0 +1,32 @@ +import { Paragraph, TextLink, Note, Flex } from '@contentful/f36-components'; +import tokens from '@contentful/f36-tokens'; + +const LocalhostWarning = () => { + return ( + + + + Contentful Apps need to run inside the Contentful web app to function properly. Install + the app into a space and render your app into one of the{' '} + + available locations + + . + +
+ + + Follow{' '} + + our guide + {' '} + to get started or{' '} + open Contentful{' '} + to manage your app. + +
+
+ ); +}; + +export default LocalhostWarning; diff --git a/examples/audit-log-viewer/src/index.tsx b/examples/audit-log-viewer/src/index.tsx new file mode 100644 index 0000000000..ffe5ad660a --- /dev/null +++ b/examples/audit-log-viewer/src/index.tsx @@ -0,0 +1,21 @@ +import { GlobalStyles } from '@contentful/f36-components'; +import { SDKProvider } from '@contentful/react-apps-toolkit'; + +import { createRoot } from 'react-dom/client'; +import App from './App'; +import LocalhostWarning from './components/LocalhostWarning'; + +const container = document.getElementById('root')!; +const root = createRoot(container); + +if (process.env.NODE_ENV === 'development' && window.self === window.top) { + // You can remove this if block before deploying your app + root.render(); +} else { + root.render( + + + + + ); +} diff --git a/examples/audit-log-viewer/src/lib/configParams.test.ts b/examples/audit-log-viewer/src/lib/configParams.test.ts new file mode 100644 index 0000000000..ce891ec71b --- /dev/null +++ b/examples/audit-log-viewer/src/lib/configParams.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { + buildParametersOnSave, + EGRESS_IPS, + emptyConfigForm, + missingRequiredParameters, + type ConfigFormState, +} from './configParams'; + +const s3Form = (): ConfigFormState => ({ + ...emptyConfigForm(), + provider: 's3', + bucketName: 'my-bucket', + region: 'eu-west-1', +}); + +describe('buildParametersOnSave', () => { + it('always includes provider and only the selected provider fields', () => { + const out = buildParametersOnSave( + { ...s3Form(), azureAccountName: 'should-not-leak' }, + {}, + ); + expect(out.provider).toBe('s3'); + expect(out.bucketName).toBe('my-bucket'); + expect(out).not.toHaveProperty('azureAccountName'); + expect(out).not.toHaveProperty('gcsBucketName'); + }); + + it('uses newly typed secrets and re-sends saved (redacted) ones when blank', () => { + const typed = buildParametersOnSave( + { ...s3Form(), awsAccessKeyId: 'AKIANEW', awsSecretAccessKey: 'new' }, + { awsAccessKeyId: '***', awsSecretAccessKey: '***' }, + ); + expect(typed.awsAccessKeyId).toBe('AKIANEW'); + const preserved = buildParametersOnSave(s3Form(), { + awsAccessKeyId: '', + awsSecretAccessKey: '', + }); + expect(preserved.awsAccessKeyId).toBe(''); + expect(preserved.awsSecretAccessKey).toBe(''); + }); + + it('azure form emits azure fields only', () => { + const out = buildParametersOnSave( + { + ...emptyConfigForm(), + provider: 'azure', + azureAccountName: 'acct', + azureContainerName: 'logs', + azureAccountKey: 'a2V5', + }, + {}, + ); + expect(out).toEqual({ + provider: 'azure', + prefix: '', + azureAccountName: 'acct', + azureContainerName: 'logs', + azureAccountKey: 'a2V5', + }); + }); + + it('gcs form emits gcs fields only, preserving a saved key', () => { + const out = buildParametersOnSave( + { ...emptyConfigForm(), provider: 'gcs', gcsBucketName: 'b' }, + { gcsServiceAccountKey: '' }, + ); + expect(out).toEqual({ + provider: 'gcs', + prefix: '', + gcsBucketName: 'b', + gcsServiceAccountKey: '', + }); + }); +}); + +describe('missingRequiredParameters', () => { + it('reports the selected provider requirements only', () => { + expect(missingRequiredParameters({ ...emptyConfigForm(), provider: 'azure' }, {})).toEqual([ + 'azureAccountName', + 'azureContainerName', + 'azureAccountKey', + ]); + expect(missingRequiredParameters(emptyConfigForm(), {})).toEqual([ + 'bucketName', + 'region', + 'awsAccessKeyId', + 'awsSecretAccessKey', + ]); + }); + + it('passes when required fields are typed or saved', () => { + expect( + missingRequiredParameters( + { ...emptyConfigForm(), provider: 'gcs', gcsBucketName: 'b' }, + { gcsServiceAccountKey: '' }, + ), + ).toEqual([]); + }); + + it('flags a newly typed GCS key that is not valid JSON with client_email/private_key', () => { + expect( + missingRequiredParameters( + { ...emptyConfigForm(), provider: 'gcs', gcsBucketName: 'b', gcsServiceAccountKey: 'not json' }, + {}, + ), + ).toEqual(['gcsServiceAccountKey (invalid JSON — expected client_email and private_key)']); + + expect( + missingRequiredParameters( + { + ...emptyConfigForm(), + provider: 'gcs', + gcsBucketName: 'b', + gcsServiceAccountKey: JSON.stringify({ client_email: 'a@b.iam.gserviceaccount.com' }), + }, + {}, + ), + ).toEqual(['gcsServiceAccountKey (invalid JSON — expected client_email and private_key)']); + }); + + it('passes a newly typed GCS key with the required client_email and private_key fields', () => { + expect( + missingRequiredParameters( + { + ...emptyConfigForm(), + provider: 'gcs', + gcsBucketName: 'b', + gcsServiceAccountKey: JSON.stringify({ + client_email: 'a@b.iam.gserviceaccount.com', + private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n', + }), + }, + {}, + ), + ).toEqual([]); + }); +}); + +describe('EGRESS_IPS', () => { + it('is unchanged', () => { + expect(EGRESS_IPS).toHaveLength(6); + }); +}); diff --git a/examples/audit-log-viewer/src/lib/configParams.ts b/examples/audit-log-viewer/src/lib/configParams.ts new file mode 100644 index 0000000000..6570bbb1b6 --- /dev/null +++ b/examples/audit-log-viewer/src/lib/configParams.ts @@ -0,0 +1,113 @@ +export type Provider = 's3' | 'azure' | 'gcs'; + +export interface ConfigFormState { + provider: Provider; + prefix: string; + // s3 + bucketName: string; + region: string; + roleArn: string; + externalId: string; + awsAccessKeyId: string; + awsSecretAccessKey: string; + // azure + azureAccountName: string; + azureContainerName: string; + azureAccountKey: string; + // gcs + gcsBucketName: string; + gcsServiceAccountKey: string; +} + +export function emptyConfigForm(): ConfigFormState { + return { + provider: 's3', + prefix: '', + bucketName: '', + region: '', + roleArn: '', + externalId: '', + awsAccessKeyId: '', + awsSecretAccessKey: '', + azureAccountName: '', + azureContainerName: '', + azureAccountKey: '', + gcsBucketName: '', + gcsServiceAccountKey: '', + }; +} + +const TEXT_FIELDS: Record> = { + s3: ['bucketName', 'region', 'roleArn', 'externalId'], + azure: ['azureAccountName', 'azureContainerName'], + gcs: ['gcsBucketName'], +}; + +export const SECRET_FIELDS: Record> = { + s3: ['awsAccessKeyId', 'awsSecretAccessKey'], + azure: ['azureAccountKey'], + gcs: ['gcsServiceAccountKey'], +}; + +const REQUIRED_TEXT: Record> = { + s3: ['bucketName', 'region'], + azure: ['azureAccountName', 'azureContainerName'], + gcs: ['gcsBucketName'], +}; + +/** + * Emit `provider`, `prefix` and only the selected provider's fields. + * Secrets read back from Contentful are redacted: send a typed value when the + * installer entered one; otherwise re-send the stored (redacted) value so the + * platform keeps the original; omit entirely when nothing was ever saved. + */ +export function buildParametersOnSave( + form: ConfigFormState, + saved: Record, +): Record { + const out: Record = { provider: form.provider, prefix: form.prefix.trim() }; + for (const key of TEXT_FIELDS[form.provider]) out[key] = (form[key] as string).trim(); + for (const key of SECRET_FIELDS[form.provider]) { + const typed = (form[key] as string).trim(); + if (typed) out[key] = typed; + else if (typeof saved[key] === 'string' && saved[key] !== '') out[key] = saved[key] as string; + } + return out; +} + +/** Names of the selected provider's required parameters that would be empty after this save. */ +export function missingRequiredParameters( + form: ConfigFormState, + saved: Record, +): string[] { + const out = buildParametersOnSave(form, saved); + const missing: string[] = REQUIRED_TEXT[form.provider].filter((k) => !out[k]); + for (const key of SECRET_FIELDS[form.provider]) if (!out[key]) missing.push(key); + const typedGcsKey = form.provider === 'gcs' ? form.gcsServiceAccountKey.trim() : ''; + if (typedGcsKey) { + let valid = false; + try { + const parsed = JSON.parse(typedGcsKey) as Record; + valid = + typeof parsed.client_email === 'string' && + parsed.client_email.length > 0 && + typeof parsed.private_key === 'string' && + parsed.private_key.length > 0; + } catch { + valid = false; + } + if (!valid) { + missing.push('gcsServiceAccountKey (invalid JSON — expected client_email and private_key)'); + } + } + return missing; +} + +export const EGRESS_IPS = [ + '104.28.4.4/32', + '104.28.4.5/32', + '104.28.4.6/32', + '104.28.4.7/32', + '2a09:bac5:fff0:95::/64', + '2a09:bac6:fff0:95::/64', +]; diff --git a/examples/audit-log-viewer/src/lib/directory.test.ts b/examples/audit-log-viewer/src/lib/directory.test.ts new file mode 100644 index 0000000000..7057f7231a --- /dev/null +++ b/examples/audit-log-viewer/src/lib/directory.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { applyDirectory, type Directory } from './directory'; +import type { AuditEvent } from './events'; + +function makeEvent(overrides: Partial = {}): AuditEvent { + return { + time: 0, + timeIso: '2026-06-29T10:00:00.000Z', + activity: 'Update', + actorType: 'User', + actorId: 'u1', + actorName: 'u1', + entityType: 'Entry', + entityId: 'e1', + spaceId: 'sp1', + spaceName: 'sp1', + path: '/spaces/sp1/entries/e1', + method: 'PUT', + status: 200, + raw: {}, + ...overrides, + }; +} + +describe('applyDirectory', () => { + it('resolves user actor names and space names from the maps', () => { + const dir: Directory = { + users: new Map([['u1', 'Jane Smith']]), + spaces: new Map([['sp1', 'My Space']]), + }; + const [resolved] = applyDirectory([makeEvent()], dir); + expect(resolved.actorName).toBe('Jane Smith'); + expect(resolved.spaceName).toBe('My Space'); + }); + + it('leaves App actors and unknown ids untouched', () => { + const dir: Directory = { + users: new Map([['u1', 'Jane Smith']]), + spaces: new Map(), + }; + const [resolved] = applyDirectory( + [makeEvent({ actorType: 'App', actorId: 'app1', actorName: 'app1', spaceId: 'unknown', spaceName: 'unknown' })], + dir, + ); + expect(resolved.actorName).toBe('app1'); + expect(resolved.spaceName).toBe('unknown'); + }); + + it('returns the same array when both maps are empty', () => { + const events = [makeEvent()]; + const dir: Directory = { users: new Map(), spaces: new Map() }; + expect(applyDirectory(events, dir)).toBe(events); + }); +}); diff --git a/examples/audit-log-viewer/src/lib/directory.ts b/examples/audit-log-viewer/src/lib/directory.ts new file mode 100644 index 0000000000..dd1d9cca76 --- /dev/null +++ b/examples/audit-log-viewer/src/lib/directory.ts @@ -0,0 +1,58 @@ +import type { PageAppSDK } from '@contentful/app-sdk'; +import type { AuditEvent } from './events'; + +export interface Directory { + users: Map; + spaces: Map; +} + +const PAGE = 100; + +/** + * Resolve actor ids → user names and space ids → space names via the CMA. + * Both lookups are best-effort: a viewer without org-level permission just + * keeps seeing raw ids. + */ +export async function fetchDirectory(sdk: PageAppSDK): Promise { + const users = new Map(); + const spaces = new Map(); + + // The Contentful host only allows space-scoped CMA actions from inside an + // app (org-scoped ones are rejected with "You can not access the action … + // from within an app"), so resolution covers members of the current space + // and the current space's name; actors from other org spaces keep raw ids. + try { + for (let skip = 0; ; skip += PAGE) { + const page = await sdk.cma.user.getManyForSpace({ + spaceId: sdk.ids.space, + query: { limit: PAGE, skip }, + }); + for (const u of page.items) { + const name = [u.firstName, u.lastName].filter(Boolean).join(' ') || u.email; + if (name) users.set(u.sys.id, name); + } + if (!Number.isFinite(page.total) || skip + PAGE >= page.total || skip >= 5000) break; + } + } catch (err) { + console.warn('[audit-log-viewer] user directory lookup failed:', err); + } + + try { + const space = await sdk.cma.space.get({}); + spaces.set(space.sys.id, space.name); + } catch (err) { + console.warn('[audit-log-viewer] space directory lookup failed:', err); + } + + return { users, spaces }; +} + +/** Overlay resolved names onto normalized events (pure). */ +export function applyDirectory(events: AuditEvent[], dir: Directory): AuditEvent[] { + if (dir.users.size === 0 && dir.spaces.size === 0) return events; + return events.map((e) => ({ + ...e, + actorName: (e.actorType === 'User' && dir.users.get(e.actorId)) || e.actorName, + spaceName: dir.spaces.get(e.spaceId) || e.spaceName, + })); +} diff --git a/examples/audit-log-viewer/src/lib/events.test.ts b/examples/audit-log-viewer/src/lib/events.test.ts new file mode 100644 index 0000000000..759d5108e8 --- /dev/null +++ b/examples/audit-log-viewer/src/lib/events.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { eventsPerDay, filterEvents, normalizeEvent, topBy, type AuditEvent } from './events'; + +const fixture = readFileSync( + join(__dirname, '../../test-fixtures/contentful-audit-TESTORG-20260630T040000000Z.json'), + 'utf8', +) + .trim() + .split('\n') + .map((l) => JSON.parse(l)); + +describe('normalizeEvent', () => { + it('maps a documented OCSF user event', () => { + const ev = normalizeEvent(fixture[0])!; + expect(ev.activity).toBe('Update'); + expect(ev.actorName).toBe('Jane Smith'); + expect(ev.actorType).toBe('User'); + expect(ev.entityType).toBe('Entry'); + expect(ev.entityId).toBe('e1'); + expect(ev.spaceId).toBe('sp1'); + expect(ev.method).toBe('PUT'); + expect(ev.status).toBe(200); + expect(ev.timeIso).toBe('2026-06-29T10:15:42.123Z'); // "time" treated as UTC + }); + + it('falls back to uid for app actors without name/email', () => { + const ev = normalizeEvent(fixture[1])!; + expect(ev.actorType).toBe('App'); + expect(ev.actorName).toBe('app1'); + }); + + it('accepts ISO timestamps too and rejects garbage', () => { + expect(normalizeEvent({ time: '2026-06-29T10:15:42.123Z' })).not.toBeNull(); + expect(normalizeEvent({ time: 'never' })).toBeNull(); + expect(normalizeEvent('nope')).toBeNull(); + expect(normalizeEvent(null)).toBeNull(); + }); + + it('matches the lowercase plural "spaces" enrichment type seen in real-world events', () => { + const ev = normalizeEvent({ + time: '2026-06-29T10:00:00.000Z', + enrichments: [ + { + name: 'http_request.url.path', + value: '/spaces/z9kk0r2h6644/environments/master/assets', + type: 'spaces', + data: { id: 'z9kk0r2h6644' }, + }, + ], + })!; + expect(ev.spaceId).toBe('z9kk0r2h6644'); + }); + + it('falls back to the URL path for space id when no enrichment is present', () => { + const ev = normalizeEvent({ + time: '2026-06-29T10:00:00.000Z', + http_request: { url: { path: '/spaces/abc123/environments/master/entries' } }, + })!; + expect(ev.spaceId).toBe('abc123'); + }); + + it('leaves spaceId empty for non-space paths like /oauth/token', () => { + const ev = normalizeEvent({ + time: '2026-06-29T10:00:00.000Z', + http_request: { url: { path: '/oauth/token' } }, + })!; + expect(ev.spaceId).toBe(''); + }); +}); + +describe('aggregations', () => { + const events = fixture.map((f) => normalizeEvent(f)!) as AuditEvent[]; + + it('eventsPerDay counts by UTC day ascending', () => { + expect(eventsPerDay(events)).toEqual([{ date: '2026-06-29', count: 2 }]); + }); + + it('topBy ranks by count descending', () => { + expect(topBy(events, (e) => e.activity, 1)).toEqual([ + expect.objectContaining({ count: 1 }), + ]); + expect(topBy(events, (e) => e.actorName)).toHaveLength(2); + }); +}); + +describe('filterEvents', () => { + const events = fixture.map((f) => normalizeEvent(f)!) as AuditEvent[]; + // events[0]: actorName 'Jane Smith', entityId 'e1', spaceId 'sp1', + // path '/spaces/sp1/environments/master/entries/e1/published' + // events[1]: actorName 'app1', entityId 'a1', spaceId 'sp1', + // path '/spaces/sp1/environments/master/assets/a1' + const otherSpaceEvent: AuditEvent = { ...events[1], spaceId: 'sp2', spaceName: 'sp2' }; + const all = [...events, otherSpaceEvent]; + + it('returns all events when no filters are set', () => { + expect(filterEvents(all, {})).toEqual(all); + }); + + it('matches spaceId exactly', () => { + const result = filterEvents(all, { spaceId: 'sp1' }); + expect(result).toEqual(events); + expect(result.some((e) => e.spaceId === 'sp2')).toBe(false); + }); + + it('matches entityId case-insensitively via query', () => { + const result = filterEvents(all, { query: 'E1' }); + expect(result.map((e) => e.entityId)).toEqual(['e1']); + }); + + it('matches a path substring via query', () => { + const result = filterEvents(all, { query: '/entries/' }); + expect(result).toHaveLength(1); + expect(result[0].path).toContain('/entries/'); + }); + + it('returns an empty array when the query matches nothing', () => { + expect(filterEvents(all, { query: 'no-such-thing-anywhere' })).toEqual([]); + }); + + it('requires both actor and query to hold when combined', () => { + const result = filterEvents(all, { actor: 'Jane Smith', query: 'a1' }); + expect(result).toEqual([]); + + const result2 = filterEvents(all, { actor: 'Jane Smith', query: 'e1' }); + expect(result2.map((e) => e.entityId)).toEqual(['e1']); + }); +}); diff --git a/examples/audit-log-viewer/src/lib/events.ts b/examples/audit-log-viewer/src/lib/events.ts new file mode 100644 index 0000000000..15d763dd65 --- /dev/null +++ b/examples/audit-log-viewer/src/lib/events.ts @@ -0,0 +1,113 @@ +export interface AuditEvent { + time: number; + timeIso: string; + activity: string; + actorType: string; + actorId: string; + actorName: string; + entityType: string; + entityId: string; + spaceId: string; + spaceName: string; + path: string; + method: string; + status?: number; + raw: Record; +} + +/** + * Normalise one OCSF Web Resource Activity event (schema 1.3.0). + * Prefers the nested actor.user fields; falls back to the deprecated + * top-level actor.type/actor.id. Docs give "time" as + * "yyyy-MM-dd hh:mm:ss.SSS" with no zone — treated as UTC. + */ +export function normalizeEvent(input: unknown): AuditEvent | null { + if (!input || typeof input !== 'object') return null; + const raw = input as Record; + const t = typeof raw.time === 'string' ? raw.time : ''; + let ms = Date.parse(t.includes('T') ? t : `${t.replace(' ', 'T')}Z`); + if (Number.isNaN(ms)) ms = Date.parse(t); + if (Number.isNaN(ms)) return null; + + const user = raw.actor?.user; + const spaceEnrichment = Array.isArray(raw.enrichments) + ? raw.enrichments.find((e: any) => typeof e?.type === 'string' && /^spaces?$/i.test(e.type)) + : undefined; + const resource = Array.isArray(raw.web_resources) ? raw.web_resources[0] : undefined; + const actorId = user?.uid ?? raw.actor?.id ?? ''; + + const path = raw.http_request?.url?.path ?? ''; + let spaceId: string = spaceEnrichment?.data?.id ?? ''; + if (!spaceId) { + const m = /^\/spaces\/([A-Za-z0-9_-]+)/.exec(path); + spaceId = m?.[1] ?? ''; + } + + return { + time: ms, + timeIso: new Date(ms).toISOString(), + activity: typeof raw.activity_name === 'string' ? raw.activity_name : 'Unknown', + actorType: user?.type ?? raw.actor?.type ?? 'Unknown', + actorId, + actorName: user?.full_name || user?.email_addr || actorId || 'Unknown', + entityType: resource?.type ?? 'Unknown', + entityId: resource?.uid ?? '', + spaceId, + spaceName: spaceId, + path, + method: raw.http_request?.http_method ?? '', + status: typeof raw.http_response?.code === 'number' ? raw.http_response.code : undefined, + raw, + }; +} + +export function eventsPerDay(events: AuditEvent[]): { date: string; count: number }[] { + const counts = new Map(); + for (const e of events) { + const d = e.timeIso.slice(0, 10); + counts.set(d, (counts.get(d) ?? 0) + 1); + } + return [...counts.entries()] + .map(([date, count]) => ({ date, count })) + .sort((a, b) => (a.date < b.date ? -1 : 1)); +} + +export interface EventFilters { + actor?: string; + activity?: string; + spaceId?: string; + query?: string; +} + +/** Case-insensitive substring search over the fields users actually hunt for. */ +export function filterEvents(events: AuditEvent[], f: EventFilters): AuditEvent[] { + const q = (f.query ?? '').trim().toLowerCase(); + return events.filter( + (e) => + (!f.actor || e.actorName === f.actor) && + (!f.activity || e.activity === f.activity) && + (!f.spaceId || e.spaceId === f.spaceId) && + (!q || + e.entityId.toLowerCase().includes(q) || + e.entityType.toLowerCase().includes(q) || + e.path.toLowerCase().includes(q) || + e.actorName.toLowerCase().includes(q) || + e.spaceName.toLowerCase().includes(q)), + ); +} + +export function topBy( + events: AuditEvent[], + key: (e: AuditEvent) => string, + n = 10, +): { name: string; count: number }[] { + const counts = new Map(); + for (const e of events) { + const k = key(e) || 'Unknown'; + counts.set(k, (counts.get(k) ?? 0) + 1); + } + return [...counts.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + .slice(0, n); +} diff --git a/examples/audit-log-viewer/src/lib/invokeAction.ts b/examples/audit-log-viewer/src/lib/invokeAction.ts new file mode 100644 index 0000000000..2882d2216c --- /dev/null +++ b/examples/audit-log-viewer/src/lib/invokeAction.ts @@ -0,0 +1,38 @@ +import type { PageAppSDK } from '@contentful/app-sdk'; +import type { ListLogFilesResult } from './types'; + +const ACTION_NAME = 'listAuditLogFiles'; + +type WireResult = + | ({ ok: true } & ListLogFilesResult) + | { ok: false; error: string }; + +export async function invokeListAction( + sdk: PageAppSDK, + params: { startDate: string; endDate: string }, +): Promise { + const { items } = await sdk.cma.appAction.getManyForEnvironment({ + spaceId: sdk.ids.space, + environmentId: sdk.ids.environment, + }); + const action = items.find( + (a) => a.name === ACTION_NAME && a.sys.appDefinition?.sys.id === sdk.ids.app, + ); + if (!action) { + throw new Error(`App action "${ACTION_NAME}" not found — run npm run configure-app`); + } + const call = await sdk.cma.appActionCall.createWithResult( + { + appDefinitionId: sdk.ids.app!, + appActionId: action.sys.id, + retries: 15, + }, + { parameters: params }, + ); + if (call.sys.status !== 'succeeded') { + throw new Error(call.sys.error?.message ?? 'App action call failed'); + } + const result = call.sys.result as WireResult; + if (!result.ok) throw new Error(result.error); + return { files: result.files, truncated: result.truncated }; +} diff --git a/examples/audit-log-viewer/src/lib/parseLogFile.test.ts b/examples/audit-log-viewer/src/lib/parseLogFile.test.ts new file mode 100644 index 0000000000..77c3a725a1 --- /dev/null +++ b/examples/audit-log-viewer/src/lib/parseLogFile.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment node +import { gzipSync } from 'node:zlib'; +import { describe, expect, it } from 'vitest'; +import { parseLogFile } from './parseLogFile'; + +const enc = (s: string) => new TextEncoder().encode(s).buffer as ArrayBuffer; +const gz = (s: string) => { + const b = gzipSync(Buffer.from(s)); + return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) as ArrayBuffer; +}; + +describe('parseLogFile', () => { + it('parses a JSON array', async () => { + expect(await parseLogFile(enc('[{"a":1},{"a":2}]'))).toEqual([{ a: 1 }, { a: 2 }]); + }); + + it('wraps a single JSON object in an array', async () => { + expect(await parseLogFile(enc('{"a":1}'))).toEqual([{ a: 1 }]); + }); + + it('parses NDJSON, skipping blank and malformed lines', async () => { + expect(await parseLogFile(enc('{"a":1}\n\nnot-json\n{"a":2}\n'))).toEqual([ + { a: 1 }, + { a: 2 }, + ]); + }); + + it('transparently gunzips (magic-byte detection)', async () => { + expect(await parseLogFile(gz('{"a":1}\n{"a":2}'))).toEqual([{ a: 1 }, { a: 2 }]); + }); + + it('returns [] for an empty file', async () => { + expect(await parseLogFile(enc(''))).toEqual([]); + }); +}); diff --git a/examples/audit-log-viewer/src/lib/parseLogFile.ts b/examples/audit-log-viewer/src/lib/parseLogFile.ts new file mode 100644 index 0000000000..a48526b4f3 --- /dev/null +++ b/examples/audit-log-viewer/src/lib/parseLogFile.ts @@ -0,0 +1,31 @@ +/** + * Contentful documents audit files only as ".json" — compression and internal + * layout (array vs NDJSON) are undocumented, so tolerate all of: + * gzip or plain; JSON array; single JSON object; newline-delimited JSON. + */ +export async function parseLogFile(buf: ArrayBuffer): Promise { + const bytes = new Uint8Array(buf); + let text: string; + if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) { + const stream = new Blob([buf]).stream().pipeThrough(new DecompressionStream('gzip')); + text = await new Response(stream).text(); + } else { + text = new TextDecoder().decode(buf); + } + const trimmed = text.trim(); + if (!trimmed) return []; + try { + const parsed = JSON.parse(trimmed); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return trimmed.split('\n').flatMap((line) => { + const l = line.trim(); + if (!l) return []; + try { + return [JSON.parse(l)]; + } catch { + return []; + } + }); + } +} diff --git a/examples/audit-log-viewer/src/lib/types.ts b/examples/audit-log-viewer/src/lib/types.ts new file mode 100644 index 0000000000..13d5df256b --- /dev/null +++ b/examples/audit-log-viewer/src/lib/types.ts @@ -0,0 +1,13 @@ +// Wire types for the listAuditLogFiles app action. +// Keep in sync with functions/lib/storage/types.ts (separate build roots — deliberate copy). +export interface LogFileRef { + key: string; + url: string; + size: number; + coveredDate: string; +} + +export interface ListLogFilesResult { + files: LogFileRef[]; + truncated: boolean; +} diff --git a/examples/audit-log-viewer/src/lib/useAuditLogs.ts b/examples/audit-log-viewer/src/lib/useAuditLogs.ts new file mode 100644 index 0000000000..0cb5b62f68 --- /dev/null +++ b/examples/audit-log-viewer/src/lib/useAuditLogs.ts @@ -0,0 +1,71 @@ +import { useCallback, useState } from 'react'; +import type { PageAppSDK } from '@contentful/app-sdk'; +import { invokeListAction } from './invokeAction'; +import { parseLogFile } from './parseLogFile'; +import { normalizeEvent, type AuditEvent } from './events'; +import { fetchDirectory, applyDirectory, type Directory } from './directory'; +import type { LogFileRef } from './types'; + +export type LoadState = + | { status: 'idle' } + | { status: 'loading'; done: number; total: number } + | { + status: 'ready'; + events: AuditEvent[]; + files: LogFileRef[]; + truncated: boolean; + failedFiles: string[]; + } + | { status: 'error'; message: string }; + +const CONCURRENCY = 4; + +let directoryPromise: Promise | null = null; + +export function useAuditLogs(sdk: PageAppSDK) { + const [state, setState] = useState({ status: 'idle' }); + + const load = useCallback( + async (startDate: string, endDate: string) => { + setState({ status: 'loading', done: 0, total: 0 }); + try { + directoryPromise ??= fetchDirectory(sdk); + const { files, truncated } = await invokeListAction(sdk, { startDate, endDate }); + setState({ status: 'loading', done: 0, total: files.length }); + const events: AuditEvent[] = []; + const failedFiles: string[] = []; + const queue = [...files]; + let done = 0; + const worker = async () => { + for (let f = queue.shift(); f; f = queue.shift()) { + try { + const res = await fetch(f.url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + for (const raw of await parseLogFile(await res.arrayBuffer())) { + const ev = normalizeEvent(raw); + if (ev) events.push(ev); + } + } catch { + failedFiles.push(f.key); + } + done += 1; + setState({ status: 'loading', done, total: files.length }); + } + }; + await Promise.all( + Array.from({ length: Math.min(CONCURRENCY, Math.max(files.length, 1)) }, worker), + ); + events.sort((a, b) => b.time - a.time); + const dir = await directoryPromise; + if (dir.users.size === 0 && dir.spaces.size === 0) directoryPromise = null; + const resolved = applyDirectory(events, dir); + setState({ status: 'ready', events: resolved, files, truncated, failedFiles }); + } catch (e) { + setState({ status: 'error', message: e instanceof Error ? e.message : String(e) }); + } + }, + [sdk], + ); + + return { state, load }; +} diff --git a/examples/audit-log-viewer/src/locations/ConfigScreen.spec.tsx b/examples/audit-log-viewer/src/locations/ConfigScreen.spec.tsx new file mode 100644 index 0000000000..92168bd9ef --- /dev/null +++ b/examples/audit-log-viewer/src/locations/ConfigScreen.spec.tsx @@ -0,0 +1,26 @@ +import ConfigScreen from './ConfigScreen'; +import { render, screen } from '@testing-library/react'; +import { mockCma, mockSdk } from '../../test/mocks'; +import { vi } from 'vitest'; + +vi.mock('@contentful/react-apps-toolkit', () => ({ + useSDK: () => mockSdk, + useCMA: () => mockCma, +})); + +describe('Config Screen component', () => { + beforeEach(() => { + mockSdk.app.getParameters = vi.fn().mockResolvedValue(null); + mockSdk.app.onConfigure = vi.fn(); + mockSdk.app.setReady = vi.fn(); + }); + + it('renders the configuration screen', async () => { + render(); + + expect( + await screen.findByText('Audit Log Viewer configuration'), + ).toBeInTheDocument(); + expect(mockSdk.app.setReady).toHaveBeenCalled(); + }); +}); diff --git a/examples/audit-log-viewer/src/locations/ConfigScreen.tsx b/examples/audit-log-viewer/src/locations/ConfigScreen.tsx new file mode 100644 index 0000000000..f2cc350455 --- /dev/null +++ b/examples/audit-log-viewer/src/locations/ConfigScreen.tsx @@ -0,0 +1,387 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + Accordion, + Box, + Flex, + FormControl, + Heading, + Note, + Select, + Text, + Textarea, + TextInput, +} from '@contentful/f36-components'; +import tokens from '@contentful/f36-tokens'; +import type { ConfigAppSDK } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { + buildParametersOnSave, + EGRESS_IPS, + emptyConfigForm, + missingRequiredParameters, + SECRET_FIELDS, + type ConfigFormState, + type Provider, +} from '../lib/configParams'; + +const iamPolicy = (bucket: string) => + JSON.stringify( + { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: 's3:ListBucket', + Resource: `arn:aws:s3:::${bucket || ''}`, + }, + { + Effect: 'Allow', + Action: 's3:GetObject', + Resource: `arn:aws:s3:::${bucket || ''}/*contentful-audit-*`, + }, + ], + }, + null, + 2, + ); + +const corsRule = JSON.stringify( + [ + { + AllowedHeaders: ['*'], + AllowedMethods: ['GET', 'HEAD'], + // Hosted app bundles are served from a sandboxed *.ctfcloud.net origin, + // not app.contentful.com — both are needed. + AllowedOrigins: ['https://app.contentful.com', 'https://*.ctfcloud.net'], + ExposeHeaders: [], + MaxAgeSeconds: 3000, + }, + ], + null, + 2, +); + +const Code = ({ children }: { children: string }) => ( +
+    {children}
+  
+); + +const SECRET_KEYS = new Set(Object.values(SECRET_FIELDS).flat()); + +const ConfigScreen = () => { + const sdk = useSDK(); + const [form, setForm] = useState(emptyConfigForm()); + const [saved, setSaved] = useState>({}); + const [restoredFromBackup, setRestoredFromBackup] = useState(false); + + const backupKey = `audit-log-config-${sdk.ids.space}-${sdk.ids.app}`; + + const writeBackup = useCallback((params: Record) => { + const safe: Record = {}; + for (const [k, v] of Object.entries(params)) { + if (!SECRET_KEYS.has(k)) safe[k] = v; + } + localStorage.setItem(backupKey, JSON.stringify(safe)); + }, [backupKey]); + + const onConfigure = useCallback(async () => { + const missing = missingRequiredParameters(form, saved); + if (missing.length > 0) { + sdk.notifier.error(`Missing required configuration: ${missing.join(', ')}`); + return false; + } + const currentState = await sdk.app.getCurrentState(); + const parameters = buildParametersOnSave(form, saved); + writeBackup(parameters); + return { parameters, targetState: currentState }; + }, [form, saved, sdk, writeBackup]); + + useEffect(() => { + sdk.app.onConfigure(() => onConfigure()); + }, [sdk, onConfigure]); + + useEffect(() => { + (async () => { + const current = (await sdk.app.getParameters()) as Record | null; + if (current && Object.keys(current).length > 0) { + setSaved(current); + const next = emptyConfigForm(); + next.provider = (['s3', 'azure', 'gcs'] as const).includes(current.provider as Provider) ? (current.provider as Provider) : 's3'; + for (const key of Object.keys(next) as Array) { + if (key === 'provider' || SECRET_KEYS.has(key)) continue; + const value = current[key]; + if (typeof value === 'string') { + (next as unknown as Record)[key] = value; + } + } + // secrets stay blank — placeholder tells the installer a value is stored + setForm(next); + writeBackup(current); + } else { + // Params are empty — check for a local backup (e.g. after a bundle update wiped them) + const raw = localStorage.getItem(backupKey); + if (raw) { + try { + const backup = JSON.parse(raw) as Record; + const next = emptyConfigForm(); + next.provider = (['s3', 'azure', 'gcs'] as const).includes(backup.provider as Provider) ? (backup.provider as Provider) : 's3'; + for (const key of Object.keys(next) as Array) { + if (key === 'provider' || SECRET_KEYS.has(key)) continue; + if (typeof backup[key] === 'string') { + (next as unknown as Record)[key] = backup[key]; + } + } + setForm(next); + setRestoredFromBackup(true); + } catch { + // Corrupted backup — ignore + } + } + } + sdk.app.setReady(); + })(); + }, [sdk, backupKey, writeBackup]); + + const set = (key: keyof ConfigFormState) => (e: React.ChangeEvent) => + setForm((f) => ({ ...f, [key]: e.target.value })); + const hasSavedSecret = (key: string) => typeof saved[key] === 'string' && saved[key] !== ''; + + return ( + + + Audit Log Viewer configuration + {restoredFromBackup && ( + + Your saved configuration was cleared — likely by a recent app update. Non-secret fields + have been restored from your browser's local backup. Re-enter your credentials and + save to confirm. + + )} + + + Storage credentials are stored as secure installation parameters and are only readable by + the app's server-side Function. The browser only ever receives short-lived, + read-only pre-signed URLs. + + + Anyone with access to this space can view the entire organization's audit logs + through this app. Install it only into a space restricted to administrators. + + + + + Storage provider + + + Switching provider replaces the saved configuration — the previous provider's + credentials are removed on save and must be re-entered if you switch back. + + + + {form.provider === 's3' && ( + <> + + S3 bucket name + + + + AWS region + + + + )} + + Key prefix + + + Only needed if your audit files live under a folder. Must end with / + + + {form.provider === 's3' && ( + <> + + AWS access key ID + + + + AWS secret access key + + + Stored as a secure parameter — never sent to the browser after saving. + + + + )} + {form.provider === 'azure' && ( + <> + + Storage account name + + + + Container name + + + + Storage account key + + + Azure portal → storage account → Access keys. Stored as a secure parameter. + + + + )} + {form.provider === 'gcs' && ( + <> + + Bucket name + + + + Service account key (JSON) +