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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/translation-companion/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# dependencies
/node_modules

# testing
/coverage

# production
/build
/dist

# dotenv environment variables file
.env
.env.*
!.env*.example

# misc
.DS_Store
.cursor
npm-debug.log*
yarn-debug.log*
yarn-error.log*
76 changes: 76 additions & 0 deletions apps/translation-companion/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Agent Guide — translation-companion

## What This App Does
Holds an App Identity (App Keys) so Translation product services can authenticate to the CMA
without a user in the loop -- for webhook-driven syncs and scheduled reconciliation jobs. The
app itself has no product logic: it exists so an `AppInstallation` (and therefore an App
Identity) exists per space + environment. It is a companion to the `translation-api` service and
is not scoped to any single feature of that service.

## Archetype
Standard Vite app. Config-screen-only app (single location), no App Actions, no Functions, no
Lambda. Mirrors `apps/remote-mcp`.

## Locations

| Location | File | Purpose |
|----------|------|---------|
| `LOCATION_APP_CONFIG` | `src/locations/ConfigScreen.tsx` | Confirms installation, states purpose, warns against uninstalling |

## Key Dependencies

| Package | Role |
|---------|------|
| `@contentful/app-sdk` | App Framework SDK |
| `@contentful/f36-components` | Forma 36 UI |
| `@contentful/react-apps-toolkit` | `useSDK()` |
| `contentful-management` | CMA (unused at runtime today; kept for parity with other Standard Vite apps) |

## App Identity setup (not done by this app's code)

App Identity/App Keys and CMA permission scopes are **not** configurable through this app's
code, this repo's CLI tooling, or any manifest file -- confirmed by grepping the vendored
`contentful-management` SDK types and `@contentful/app-scripts`' `create-app-definition`
implementation, neither of which exposes an App Keys or scopes field. They're set up out of
band:

1. **App Definition**: `scripts/actions/createAppDefinition.ts` (root `scripts/` package).
2. **App Keys**: generated manually by an org admin via the App Definition's **Security** tab in
the Contentful web app ("Generate a key pair"). Not scripted -- this happens once per
environment tier (test/staging/prod) and is never repeated, so there's little to amortize by
scripting it, and Contentful's `organization.createAppKey()` API offers no safer handoff than
the UI anyway (both put the raw private key in front of a human who then copies it into
Secrets Manager).
**Contentful returns the generated private key exactly once, at generation time.** There is
no way to retrieve it again afterward -- if it's lost, the only recovery is generating a new
key and re-distributing it.
3. **Scopes** (Entry read, ContentType read, Locale read for `translation-api`'s current needs):
set manually via the App Definition's Permissions tab in the Contentful web app. Extend
incrementally as new Translation features attach to this same App Definition -- don't
pre-grant scopes nothing currently uses.
4. **Install**: `scripts/actions/installApp.ts` (root `scripts/` package), run explicitly per
target space + environment by an org admin. Deliberately not self-service or
lazily-triggered by a consuming service's first request -- that would need "manage apps"
permission most callers won't have.

The generated private key is handed to the consuming service (e.g. `translation-api`) via AWS
Secrets Manager. This app and this repo never hold or transmit the private key at runtime.

## Sharp Edges & Invariants

- **This app never touches the App Identity private key at runtime.** JWT signing and App
Access Token exchange happen entirely in the consuming service (e.g. `translation-api`, using
`@contentful/node-apps-toolkit`'s `getManagementToken()`).
- **Uninstalling the `AppInstallation` breaks every consuming service immediately** -- App
Access Token exchange requires a live installation for the target space + environment. The
ConfigScreen calls this out explicitly.
- Not published to the Marketplace. Visible in a space's "Installed apps" list like any other
app -- there is no true hidden/system-app mechanism in this repo today.
- Owned by Applied AI Solutions (`@contentful/group-applied-ai-solutions`).

## Never / Always

- **Never** add product logic, App Actions, or Functions to this app to serve a specific
Translation feature -- keep it purely an identity holder. Feature-specific logic belongs in
the consuming service.
- **Always** call `sdk.app.setReady()` after initialization in ConfigScreen.
53 changes: 53 additions & 0 deletions apps/translation-companion/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Translation Companion

> **Internal tool**
> This app exists solely to hold an App Identity for Translation product services that need to
> call the Content Management API without a user in the loop (webhook-driven syncs, scheduled
> reconciliation jobs). It is not published to the Contentful Marketplace and should not be
> treated as a customer-facing product.

## Why this app exists

Some Translation product services run without any end user in the loop -- for example, a
service keeping a content index fresh via webhooks and a periodic reconciliation job has no
request-scoped bearer token available for either call site. Contentful's App Framework solves
this with **App Identity**: an App Definition with App Keys can mint short-lived App Access
Tokens independent of any user session.

This app is deliberately minimal. Installing it into a space + environment gives that
installation an App Identity; a consuming service (not this app) signs a JWT with the resulting
private key and exchanges it for an App Access Token to call the CMA.

This app is a companion to the `translation-api` service, and is intentionally **not** scoped to
a single feature of that service -- any future Translation feature needing unattended CMA access
can reuse this same App Definition rather than provisioning a new one.

## What this app is not

- Not a place for feature-specific logic. It has no App Actions, no Functions, no Lambda -- just
a config screen confirming the installation exists. All CMA-calling logic lives in the
consuming service.

## Local development

```bash
npm install
npm start
```

`npm start` creates or updates the App Definition in your configured Contentful organization and
runs the app locally. See `AGENTS.md` for how App Identity/App Keys and CMA scopes are
provisioned -- none of that happens through this app's code or `npm start`.

## Deployment

```bash
npm run build
npm run deploy # production
npm run deploy:test # test/staging
```

Requires `DEFINITIONS_ORG_ID`/`DEV_TESTING_ORG_ID` and a CMA token in the environment, matching
every other app in this repo. The `--definition-id` values in `package.json`'s `deploy`/
`deploy:test` scripts are placeholders until the App Definition is actually provisioned (see
`AGENTS.md`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# ADR-0001: App Identity for unattended CMA access

**Date:** 2026-08-06
**Status:** Accepted
**Deciders:** Adrian Meyer

## Context

This app is a companion to the `translation-api` service, which needs to make Content
Management API (CMA) calls from a webhook consumer and a periodic reconciliation job. Neither has a request-scoped user token available -- there's no end user in
the loop for a webhook delivery or a scheduled job, unlike every other CMA usage in that
service.

Contentful's App Framework offers a few ways to give a service like this CMA access:

1. **App Identity.** An App Definition with App Keys can mint short-lived App Access Tokens
fully independent of any user session -- see
[App Identity](https://www.contentful.com/developers/docs/extensibility/app-framework/app-identity/)
in Contentful's docs.
2. **Delegated App Access Tokens**, which compute effective permissions as the intersection of
an app's permissions and a specific attending user's permissions. Not applicable here --
there's no user to intersect with in a webhook delivery or a cron job.
3. **App Functions (App Event Handlers)**, which run inside Contentful's own runtime and receive
a pre-authenticated CMA client. Ruled out for now: it would move `translation-api`'s
CMA-resolution logic into this repo instead, a materially different design from how that
service is currently built.

## Decision

Provision a standalone App Definition (`translation-companion`) whose only job is to hold an App
Identity. `translation-api` signs JWTs and exchanges them for App Access Tokens itself, using
the private key generated for this App Definition. This repo never handles the private key at
runtime.

The app is scoped and named generically, as a companion to the Translation product's unattended
CMA needs, rather than to a single feature -- so any future Translation feature with the same
need can reuse this App Definition instead of provisioning a new one.

## Consequences

### Positive
- Uses App Identity, an App Framework mechanism designed for exactly this case, rather than
building a custom credential-exchange mechanism.
- Reusable by future Translation features needing the same unattended-access shape, without
renaming or re-provisioning.

### Negative
- Installation is admin-mediated per space + environment, so rolling this out across many
spaces is a manual, ongoing operational task.

### Neutral
- CMA scopes are granted via the Contentful web app's Permissions tab, not through any tooling
in this repo -- there's no manifest or CLI surface for it yet.
43 changes: 43 additions & 0 deletions apps/translation-companion/eslint.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
import { defineConfig } from "eslint/config";
import unusedImports from "eslint-plugin-unused-imports";

export default defineConfig([
{ ignores: ["build/**"] },
{settings: {
react: {
version: "detect",
},
}},
{
plugins: {
"unused-imports": unusedImports,
react: pluginReact,
tseslint: tseslint,
},
},
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
{
rules: {
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"@typescript-eslint/no-explicit-any": "off",
"unused-imports/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}
]
}
},
]);
21 changes: 21 additions & 0 deletions apps/translation-companion/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>

<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start`.
To create a production bundle, use `npm run build`.
-->
</body>
</html>
Loading
Loading