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 0000000000..5b8104a609 Binary files /dev/null and b/examples/audit-log-viewer/assets/logo.png differ 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..7bb14c3435 --- /dev/null +++ b/examples/audit-log-viewer/functions/lib/storage/s3.ts @@ -0,0 +1,117 @@ +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 wildcard ("*.amazonaws.com") stays static. Note: path-style + * addressing is deprecated by AWS for buckets created after Sep 2020 and is + * unsupported in some newer opt-in regions. If you encounter NoSuchBucket or + * endpoint errors, remove forcePathStyle and add your bucket's regional + * hostname to allowNetworks in contentful-app-manifest.json instead. + */ +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..4116d2bdc6 --- /dev/null +++ b/examples/audit-log-viewer/functions/lib/storage/types.ts @@ -0,0 +1,44 @@ +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; +} + +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) +