diff --git a/.gitignore b/.gitignore index fd4f2b066b..84888f4ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .DS_Store +.superpowers diff --git a/app.js b/app.js deleted file mode 100644 index 5ab128e4b4..0000000000 --- a/app.js +++ /dev/null @@ -1,61 +0,0 @@ -const express = require("express"); -const app = express(); -const port = process.env.PORT || 3001; - -app.get("/", (req, res) => res.type('html').send(html)); - -const server = app.listen(port, () => console.log(`Example app listening on port ${port}!`)); - -server.keepAliveTimeout = 120 * 1000; -server.headersTimeout = 120 * 1000; - -const html = ` - - - - Hello from Render! - - - - - -
- Hello from Render! -
- - -` diff --git a/data/express-templates.json b/data/express-templates.json new file mode 100644 index 0000000000..79fb4b86fc --- /dev/null +++ b/data/express-templates.json @@ -0,0 +1,3 @@ +[ + { "id": "img_1", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" } +] diff --git a/docs/superpowers/plans/2026-07-13-express-edit-flow.md b/docs/superpowers/plans/2026-07-13-express-edit-flow.md new file mode 100644 index 0000000000..b36d079cae --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-express-edit-flow.md @@ -0,0 +1,705 @@ +# Express Image Edit Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a WhatsApp user ask what edits are allowed on a previously-sent graphic, request an edit, have it validated against the graphic's unlocked layers, applied via a mocked Adobe Express API, uploaded via a mocked Meta upload, and re-sent as an updated image — all with placeholder Express/Meta calls that can be swapped for real ones later. + +**Architecture:** Three new small, dependency-free modules (`imageStore.js` for per-phone-number tracked-image state, `expressApi.js` for mock template/edit calls, `metaUpload.js` for the mock media upload) get unit tests via Node's built-in test runner. A fourth new module, `actions.js`, extracts all GPT tool-call action handlers (including the two existing ones, `list_campaign_graphics` and `generate_bulk_graphics`, moved unchanged) so the new `edit_graphic` logic can be unit-tested with a fake `sendImage` — no real WhatsApp/OpenAI credentials needed for tests. `app.js` is left as the thin orchestrator: WhatsApp API calls, GPT tool schemas/system prompt, and the webhook route wiring args into `actions.js`. + +**Tech Stack:** Node.js 20, Express 5, `openai` SDK, Node's built-in `node:test` + `node:assert/strict` (no new dependencies). + +## Global Constraints + +- No real Adobe Express or Meta API calls — all such calls are placeholder/mock functions returning mock values (per spec, these get swapped for real implementations later). +- Actually sending the initial graphic image is out of scope — tracked images are pre-seeded in memory at first access per phone number (spec: 3 fixed seed entries: Diwali Offer Banner / Summer Sale Flyer / New Arrival Poster). +- State is in-memory only, no persistence across restarts (matches existing `conversationHistory` pattern in app.js). +- No new npm dependencies — use Node's built-in `node:test` test runner. +- Follow existing code style: CommonJS `require`, 2-space indentation, semicolons. + +--- + +## Task 1: `imageStore.js` — tracked-image state + +**Files:** +- Create: `imageStore.js` +- Test: `imageStore.test.js` +- Modify: `package.json` (add a `test` script) + +**Interfaces:** +- Produces: `getTrackedImages(phoneNumber) -> Array<{ id: string, name: string, templateId: string, currentEdits: object }>` (seeds 3 fixed entries on first call for a phone number, returns the same array reference on later calls). +- Produces: `findTrackedImage(phoneNumber, imageId) -> object | undefined`. + +- [ ] **Step 1: Write the failing test** + +Create `imageStore.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { getTrackedImages, findTrackedImage } = require('./imageStore'); + +test('getTrackedImages seeds 3 images on first access', () => { + const images = getTrackedImages('111'); + assert.equal(images.length, 3); + assert.deepEqual(images.map((img) => img.id), ['img_1', 'img_2', 'img_3']); + assert.deepEqual(images[0].currentEdits, {}); +}); + +test('getTrackedImages returns the same array on repeated calls for the same phone number', () => { + const first = getTrackedImages('222'); + first[0].currentEdits.headline = 'Flash Sale'; + const second = getTrackedImages('222'); + assert.equal(second[0].currentEdits.headline, 'Flash Sale'); +}); + +test('getTrackedImages seeds independently per phone number', () => { + getTrackedImages('333')[0].currentEdits.headline = 'Only for 333'; + const other = getTrackedImages('444'); + assert.deepEqual(other[0].currentEdits, {}); +}); + +test('findTrackedImage returns the matching image', () => { + getTrackedImages('555'); + const image = findTrackedImage('555', 'img_2'); + assert.equal(image.name, 'Summer Sale Flyer'); +}); + +test('findTrackedImage returns undefined for an unknown id', () => { + getTrackedImages('666'); + const image = findTrackedImage('666', 'img_999'); + assert.equal(image, undefined); +}); +``` + +- [ ] **Step 2: Add the test script and run to verify failure** + +Modify `package.json` scripts section to: + +```json +"scripts": { + "start": "node app.js", + "test": "node --test" +}, +``` + +Run: `node --test imageStore.test.js` +Expected: FAIL — `Cannot find module './imageStore'` + +- [ ] **Step 3: Write the implementation** + +Create `imageStore.js`: + +```js +const SEED_IMAGES = [ + { id: 'img_1', name: 'Diwali Offer Banner', templateId: 'tpl_diwali' }, + { id: 'img_2', name: 'Summer Sale Flyer', templateId: 'tpl_summer' }, + { id: 'img_3', name: 'New Arrival Poster', templateId: 'tpl_newarrival' }, +]; + +const trackedImages = new Map(); + +function getTrackedImages(phoneNumber) { + if (!trackedImages.has(phoneNumber)) { + trackedImages.set( + phoneNumber, + SEED_IMAGES.map((image) => ({ ...image, currentEdits: {} })) + ); + } + return trackedImages.get(phoneNumber); +} + +function findTrackedImage(phoneNumber, imageId) { + return getTrackedImages(phoneNumber).find((image) => image.id === imageId); +} + +module.exports = { getTrackedImages, findTrackedImage }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test imageStore.test.js` +Expected: PASS — 5 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add imageStore.js imageStore.test.js package.json +git commit -m "feat: add in-memory tracked-image store" +``` + +--- + +## Task 2: `expressApi.js` — mock Adobe Express calls + +**Files:** +- Create: `expressApi.js` +- Test: `expressApi.test.js` + +**Interfaces:** +- Consumes: nothing (standalone module). +- Produces: `getTemplateInfo(templateId) -> { templateId: string, unlockedLayers: string[] }`. +- Produces: `applyEdit(templateId, currentEdits, newEdits) -> { mergedEdits: object, renderedImageUrl: string }`. + +- [ ] **Step 1: Write the failing test** + +Create `expressApi.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { getTemplateInfo, applyEdit } = require('./expressApi'); + +test('getTemplateInfo returns the unlocked layers for a known template', () => { + const info = getTemplateInfo('tpl_diwali'); + assert.deepEqual(info, { + templateId: 'tpl_diwali', + unlockedLayers: ['discount_text', 'headline', 'background_color'], + }); +}); + +test('getTemplateInfo returns an empty layer list for an unknown template', () => { + const info = getTemplateInfo('tpl_does_not_exist'); + assert.deepEqual(info.unlockedLayers, []); +}); + +test('applyEdit merges new edits on top of current edits', () => { + const result = applyEdit('tpl_diwali', { headline: 'Old Headline' }, { discount_text: '70%' }); + assert.deepEqual(result.mergedEdits, { headline: 'Old Headline', discount_text: '70%' }); +}); + +test('applyEdit returns a rendered image url that references the template', () => { + const result = applyEdit('tpl_summer', {}, { headline: 'Flash Sale' }); + assert.match(result.renderedImageUrl, /^https:\/\/mock-express\.local\/render\/tpl_summer\?rev=\d+$/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test expressApi.test.js` +Expected: FAIL — `Cannot find module './expressApi'` + +- [ ] **Step 3: Write the implementation** + +Create `expressApi.js`: + +```js +const TEMPLATE_LAYERS = { + tpl_diwali: ['discount_text', 'headline', 'background_color'], + tpl_summer: ['headline', 'font_color'], + tpl_newarrival: ['headline'], +}; + +function getTemplateInfo(templateId) { + return { + templateId, + unlockedLayers: TEMPLATE_LAYERS[templateId] || [], + }; +} + +let renderRevision = 0; + +function applyEdit(templateId, currentEdits, newEdits) { + const mergedEdits = { ...currentEdits, ...newEdits }; + renderRevision += 1; + return { + mergedEdits, + renderedImageUrl: `https://mock-express.local/render/${templateId}?rev=${renderRevision}`, + }; +} + +module.exports = { getTemplateInfo, applyEdit }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test expressApi.test.js` +Expected: PASS — 4 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add expressApi.js expressApi.test.js +git commit -m "feat: add mock Adobe Express API module" +``` + +--- + +## Task 3: `metaUpload.js` — mock Meta media upload + +**Files:** +- Create: `metaUpload.js` +- Test: `metaUpload.test.js` + +**Interfaces:** +- Consumes: nothing (standalone module). +- Produces: `uploadImageToMeta(renderedImageUrl) -> Promise` (mock CDN URL). + +- [ ] **Step 1: Write the failing test** + +Create `metaUpload.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { uploadImageToMeta } = require('./metaUpload'); + +test('uploadImageToMeta returns a mock CDN url', async () => { + const url = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + assert.match(url, /^https:\/\/mock-meta-cdn\.local\/media\/[0-9a-f-]+\.png$/); +}); + +test('uploadImageToMeta returns a different url on each call', async () => { + const first = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + const second = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=2'); + assert.notEqual(first, second); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test metaUpload.test.js` +Expected: FAIL — `Cannot find module './metaUpload'` + +- [ ] **Step 3: Write the implementation** + +Create `metaUpload.js`: + +```js +const { randomUUID } = require('node:crypto'); + +async function uploadImageToMeta(renderedImageUrl) { + return `https://mock-meta-cdn.local/media/${randomUUID()}.png`; +} + +module.exports = { uploadImageToMeta }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test metaUpload.test.js` +Expected: PASS — 2 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add metaUpload.js metaUpload.test.js +git commit -m "feat: add mock Meta media upload module" +``` + +--- + +## Task 4: `actions.js` — extract and extend GPT tool-call action handlers + +**Files:** +- Create: `actions.js` +- Test: `actions.test.js` + +**Interfaces:** +- Consumes: `getTrackedImages`, `findTrackedImage` from `./imageStore` (Task 1); `getTemplateInfo`, `applyEdit` from `./expressApi` (Task 2); `uploadImageToMeta` from `./metaUpload` (Task 3). +- Produces: `actionListCampaignGraphics() -> Promise` (unchanged behavior, moved from app.js). +- Produces: `actionCheckAllowedEdits(phoneNumber, imageId) -> Promise`. +- Produces: `actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) -> Promise` — `sendImage` is injected `(to, link) => Promise` so tests don't need real WhatsApp calls. +- Produces: `actionGenerateBulkGraphics(filename) -> Promise` (unchanged behavior, moved from app.js). + +- [ ] **Step 1: Write the failing test** + +Create `actions.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const { getTrackedImages } = require('./imageStore'); + +test('actionCheckAllowedEdits lists the unlocked layers for a known image', async () => { + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + assert.match(reply, /Diwali Offer Banner/); + assert.match(reply, /discount_text/); +}); + +test('actionCheckAllowedEdits reports unknown images without throwing', async () => { + const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + assert.match(reply, /couldn't find that image/); +}); + +test('actionEditGraphic rejects edits outside the unlocked layers and sends nothing', async () => { + let sendImageCalled = false; + const sendImage = async () => { + sendImageCalled = true; + }; + + const reply = await actionEditGraphic('phone-3', 'img_3', { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('actionEditGraphic applies an allowed edit, sends the updated image, and remembers the edit', async () => { + const sentCalls = []; + const sendImage = async (to, link) => { + sentCalls.push({ to, link }); + }; + + const reply = await actionEditGraphic('phone-4', 'img_2', { headline: 'Flash Sale' }, { sendImage }); + + assert.match(reply, /Updated "Summer Sale Flyer"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-4'); + assert.match(sentCalls[0].link, /^https:\/\/mock-meta-cdn\.local\//); + + const image = getTrackedImages('phone-4').find((img) => img.id === 'img_2'); + assert.equal(image.currentEdits.headline, 'Flash Sale'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test actions.test.js` +Expected: FAIL — `Cannot find module './actions'` + +- [ ] **Step 3: Write the implementation** + +Create `actions.js`: + +```js +const { getTrackedImages, findTrackedImage } = require('./imageStore'); +const { getTemplateInfo, applyEdit } = require('./expressApi'); +const { uploadImageToMeta } = require('./metaUpload'); + +function formatUnknownImageMessage(phoneNumber) { + const images = getTrackedImages(phoneNumber); + const list = images.map((image) => `- ${image.name}`).join('\n'); + return `I couldn't find that image. Here's what I have:\n${list}`; +} + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster'; +} + +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + const { unlockedLayers } = getTemplateInfo(image.templateId); + const layerList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); + return `Edits allowed on "${image.name}":\n${layerList}`; +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + const { unlockedLayers } = getTemplateInfo(image.templateId); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !unlockedLayers.includes(key)); + + if (disallowedKeys.length > 0) { + const allowedList = unlockedLayers.map((layer) => `- ${layer}`).join('\n'); + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". Allowed edits:\n${allowedList}`; + } + + const { mergedEdits, renderedImageUrl } = applyEdit(image.templateId, image.currentEdits, edits); + image.currentEdits = mergedEdits; + + const uploadedUrl = await uploadImageToMeta(renderedImageUrl); + await sendImage(phoneNumber, uploadedUrl); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + return `Updated "${image.name}":\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test actions.test.js` +Expected: PASS — 4 tests passing + +- [ ] **Step 5: Run the full test suite** + +Run: `node --test` +Expected: PASS — all tests across `imageStore.test.js`, `expressApi.test.js`, `metaUpload.test.js`, `actions.test.js` (15 tests total) + +- [ ] **Step 6: Commit** + +```bash +git add actions.js actions.test.js +git commit -m "feat: extract GPT action handlers and add image-edit validation flow" +``` + +--- + +## Task 5: Wire `app.js` to the new modules + +**Files:** +- Modify: `app.js:1-2` (imports) +- Modify: `app.js:59-120` (tool definitions) +- Modify: `app.js:122-143` (delete — action handlers now live in `actions.js`) +- Modify: `app.js:147-174` (`decideAction` system prompt) +- Modify: `app.js:218-245` (webhook switch statement) + +**Interfaces:** +- Consumes: `getTrackedImages` from `./imageStore` (Task 1); `actionListCampaignGraphics`, `actionCheckAllowedEdits`, `actionEditGraphic`, `actionGenerateBulkGraphics` from `./actions` (Task 4). +- Produces: nothing new — this task is pure wiring, verified manually (no unit test; this is the orchestration layer that touches the real WhatsApp/OpenAI SDKs already excluded from automated testing per the spec). + +- [ ] **Step 1: Update imports** + +At the top of `app.js`, replace: + +```js +const express = require('express'); +const OpenAI = require('openai'); +``` + +with: + +```js +const express = require('express'); +const OpenAI = require('openai'); +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); +``` + +- [ ] **Step 2: Add `image_id` to the `check_allowed_edits` and `edit_graphic` tool definitions** + +In the `tools` array, replace the `check_allowed_edits` entry: + +```js + { + type: 'function', + function: { + name: 'check_allowed_edits', + description: 'Check what edits are permitted on the current graphic', + parameters: { type: 'object', properties: {} }, + }, + }, +``` + +with: + +```js + { + type: 'function', + function: { + name: 'check_allowed_edits', + description: + 'Check what edits are permitted on a specific graphic. Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image the user is asking about, from the tracked images list' }, + }, + required: ['image_id'], + }, + }, + }, +``` + +And replace the `edit_graphic` entry: + +```js + { + type: 'function', + function: { + name: 'edit_graphic', + description: 'Edit the current graphic via Adobe Express API (e.g. change discount text, colors)', + parameters: { + type: 'object', + properties: { + edits: { + type: 'object', + description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }', + }, + }, + required: ['edits'], + }, + }, + }, +``` + +with: + +```js + { + type: 'function', + function: { + name: 'edit_graphic', + description: + 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, + edits: { + type: 'object', + description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }', + }, + }, + required: ['image_id', 'edits'], + }, + }, + }, +``` + +- [ ] **Step 3: Delete the old inline action handlers** + +Delete this entire block (now provided by `actions.js`): + +```js +// ── Action handlers (stubs — wire real APIs here) ──────────────────────────── + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. New Arrival Poster'; +} + +async function actionCheckAllowedEdits() { + // TODO: fetch from Adobe Express API + return 'Edits allowed on the current graphic:\n- Discount percentage\n- Headline text\n- Background color\n- Font color'; +} + +async function actionEditGraphic(edits) { + // TODO: call Adobe Express API + const summary = Object.entries(edits).map(([k, v]) => `• ${k}: ${v}`).join('\n'); + return `Graphic updated successfully:\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} +``` + +- [ ] **Step 4: Add the tracked-images list to the `decideAction` system prompt** + +Replace: + +```js +async function decideAction(phoneNumber, userMessage) { + const last3 = getHistory(phoneNumber).slice(-3); + + const messages = [ + { + role: 'system', + content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express. +Analyze the user's message and conversation history, then call the appropriate tool. +Always call exactly one tool — never reply with plain text. +If the request is ambiguous or missing details, use ask_for_more_information.`, + }, + ...last3, + { role: 'user', content: userMessage }, + ]; +``` + +with: + +```js +async function decideAction(phoneNumber, userMessage) { + const last3 = getHistory(phoneNumber).slice(-3); + const trackedImages = getTrackedImages(phoneNumber); + const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); + + const messages = [ + { + role: 'system', + content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express. +Analyze the user's message and conversation history, then call the appropriate tool. +Always call exactly one tool — never reply with plain text. +If the request is ambiguous or missing details, use ask_for_more_information. + +Images previously sent to this user (reference by id): +${imagesList}`, + }, + ...last3, + { role: 'user', content: userMessage }, + ]; +``` + +- [ ] **Step 5: Pass `phoneNumber`/`image_id`/`sendImage` through the webhook switch** + +Replace: + +```js + case 'check_allowed_edits': + await sendText(phoneNumber, '⏳ Checking allowed edits...'); + replyText = await actionCheckAllowedEdits(); + break; + + case 'edit_graphic': + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + replyText = await actionEditGraphic(args.edits); + break; +``` + +with: + +```js + case 'check_allowed_edits': + await sendText(phoneNumber, '⏳ Checking allowed edits...'); + replyText = await actionCheckAllowedEdits(phoneNumber, args.image_id); + break; + + case 'edit_graphic': + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + break; +``` + +- [ ] **Step 6: Verify the file is syntactically valid** + +Run: `node --check app.js` +Expected: no output (silent success) + +- [ ] **Step 7: Verify the server still boots and the webhook-verification route still works** + +Run: + +```bash +VERIFY_TOKEN=test WHATSAPP_PHONE_NUMBER_ID=123 WHATSAPP_TOKEN=test OPENAI_API_KEY=test node app.js & +SERVER_PID=$! +sleep 1 +curl -s "http://localhost:3000/?hub.mode=subscribe&hub.verify_token=test&hub.challenge=hello123" +kill $SERVER_PID +``` + +Expected: `hello123` printed by curl, followed by `WEBHOOK VERIFIED` in the server's stdout before it's killed. + +- [ ] **Step 8: Run the full test suite once more to confirm nothing broke** + +Run: `node --test` +Expected: PASS — all 15 tests passing + +- [ ] **Step 9: Commit** + +```bash +git add app.js +git commit -m "feat: wire image-edit flow into the webhook and GPT tool schema" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** template/layer lookup (Task 2), allowed-edit validation (Task 4 `actionEditGraphic`), Express edit + Meta upload + re-send (Task 4 + Task 5 wiring), image reference inferred by GPT from a system-prompt-provided list (Task 5 Step 4), pre-seeded mock images since real sending is out of scope (Task 1) — all covered. +- **Placeholder scan:** no TBDs; the two `// TODO: fetch from campaign API` / `// TODO: parse CSV/Excel...` comments are carried over unchanged from the existing code (out of scope for this feature) and are intentional, not gaps in this plan. +- **Type consistency:** `sendImage` signature `(to, link) => Promise` is consistent between `actions.js`'s `actionEditGraphic` (Task 4) and its call site in `app.js` (Task 5 Step 5), which passes the existing `sendImage(to, link)` from `app.js`. `image.id`/`image.name`/`image.templateId`/`image.currentEdits` field names are consistent across `imageStore.js`, `expressApi.js` consumers, and `actions.js`. diff --git a/docs/superpowers/plans/2026-07-16-real-express-api-integration.md b/docs/superpowers/plans/2026-07-16-real-express-api-integration.md new file mode 100644 index 0000000000..4762840bb3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-real-express-api-integration.md @@ -0,0 +1,1039 @@ +# Real Adobe Express API Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace every mocked/hardcoded piece of the image-edit flow with real Adobe Express API calls (`tagged-documents`, `generate-variation`, `status`), driven by a shared `docId` catalog, ending with the real edited-image `thumbnailUrl` sent back over WhatsApp. + +**Architecture:** A new `expressAuth.js` handles IMS OAuth token fetch/cache. `expressApi.js` is rewritten to make real HTTP calls (tagged-document lookup, variation generation, status polling) plus small formatting/helper functions, using `expressAuth.js` for auth headers. `imageStore.js` is rewritten to read a shared JSON catalog (`data/express-templates.json`, `id`/`name`/`docId`) fresh on every call instead of hardcoded seed data, layering in-memory per-conversation edit state on top. `actions.js` is rewired to call the real `expressApi.js` functions (via a namespace import so tests can stub them directly) instead of the old mocks, with all Express API failures caught and turned into friendly WhatsApp replies. `app.js` gets a few extra labeled `console.log` lines for webhook fields that might carry docID metadata — no other behavior change. `metaUpload.js` is left completely untouched and unused. + +**Tech Stack:** Node.js 20, Express 5, `openai` SDK, Node's built-in `node:test` + `node:assert/strict` + global `fetch` (no new dependencies). + +## Global Constraints + +- No new npm dependencies — use Node's built-in `node:test` test runner and global `fetch`, stubbed directly in tests (no mocking library). +- Follow existing code style: CommonJS `require`, 2-space indentation, semicolons. +- Real Adobe Express HTTP errors must never reach the WhatsApp user raw — every call site catches and replaces them with a short friendly retry message, logging the technical detail (status/body/docId/jobId) server-side via `console.error`. +- `data/express-templates.json` is committed to git (not gitignored) and is a flat, phone-number-agnostic catalog: `[{ id, name, docId }]`. No per-phone-number entries. +- IMS token responses' `expires_in` is treated as **milliseconds** (Adobe IMS v3 convention), added directly to `Date.now()` — flagged as an assumption to verify against the real endpoint; isolated to one line in `expressAuth.js` if it needs to change to `* 1000`. +- `metaUpload.js` and `metaUpload.test.js` stay in the repo, completely unmodified, and are not imported anywhere after this change. +- A failed or timed-out edit must not be recorded into the per-conversation edit state — only a successful `generate-variation` + `status: succeeded` commits the edit, so retries don't compound bad state. + +--- + +## Task 1: `expressAuth.js` — IMS token fetch/cache + +**Files:** +- Create: `expressAuth.js` +- Test: `expressAuth.test.js` + +**Interfaces:** +- Consumes: env vars `EXPRESS_CLIENT_ID`, `EXPRESS_CLIENT_SECRET`, `EXPRESS_API_SCOPE` (optional), `EXPRESS_IMS_TOKEN_URL` (optional). +- Produces: `getAccessToken() -> Promise` (cached IMS access token, refetches within 60s of expiry). `buildAuthHeaders() -> Promise<{ Authorization: string, 'X-API-KEY': string }>`. + +- [ ] **Step 1: Write the failing tests** + +Create `expressAuth.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const originalFetch = global.fetch; + +function freshExpressAuth() { + delete require.cache[require.resolve('./expressAuth')]; + return require('./expressAuth'); +} + +test('getAccessToken fetches a token from the IMS endpoint using client credentials', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async (url, options) => { + assert.equal(url, 'https://ims-na1.adobelogin.com/ims/token/v3'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers['Content-Type'], 'application/x-www-form-urlencoded'); + const body = options.body.toString(); + assert.match(body, /grant_type=client_credentials/); + assert.match(body, /client_id=client-123/); + assert.match(body, /client_secret=secret-456/); + return { ok: true, json: async () => ({ access_token: 'tok-abc', expires_in: 86400000, token_type: 'bearer' }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const token = await getAccessToken(); + + assert.equal(token, 'tok-abc'); + global.fetch = originalFetch; +}); + +test('getAccessToken caches the token and does not refetch on a second call', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: 'tok-cached', expires_in: 86400000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-cached'); + assert.equal(second, 'tok-cached'); + assert.equal(fetchCalls, 1); + global.fetch = originalFetch; +}); + +test('getAccessToken refetches once the cached token is within 60s of expiring', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: `tok-${fetchCalls}`, expires_in: 30000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-1'); + assert.equal(second, 'tok-2'); + assert.equal(fetchCalls, 2); + global.fetch = originalFetch; +}); + +test('getAccessToken throws with the status and body when the IMS endpoint errors', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async () => ({ ok: false, status: 401, text: async () => 'invalid client' }); + + const { getAccessToken } = freshExpressAuth(); + await assert.rejects(() => getAccessToken(), /401/); + global.fetch = originalFetch; +}); + +test('buildAuthHeaders returns Authorization and X-API-KEY headers', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-789'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-000'; + global.fetch = async () => ({ ok: true, json: async () => ({ access_token: 'tok-xyz', expires_in: 86400000 }) }); + + const { buildAuthHeaders } = freshExpressAuth(); + const headers = await buildAuthHeaders(); + + assert.deepEqual(headers, { Authorization: 'Bearer tok-xyz', 'X-API-KEY': 'client-789' }); + global.fetch = originalFetch; +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test expressAuth.test.js` +Expected: FAIL — `Cannot find module './expressAuth'` + +- [ ] **Step 3: Write the implementation** + +Create `expressAuth.js`: + +```js +const IMS_TOKEN_URL = process.env.EXPRESS_IMS_TOKEN_URL || 'https://ims-na1.adobelogin.com/ims/token/v3'; +const DEFAULT_SCOPE = 'ee.express_api,openid,AdobeID,read_organizations,additional_info.projectedProductContext'; +const REFRESH_MARGIN_MS = 60_000; + +let cachedToken = null; // { accessToken, expiresAt } + +async function getAccessToken() { + if (cachedToken && cachedToken.expiresAt - Date.now() > REFRESH_MARGIN_MS) { + return cachedToken.accessToken; + } + + const response = await fetch(IMS_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: process.env.EXPRESS_CLIENT_ID, + client_secret: process.env.EXPRESS_CLIENT_SECRET, + scope: process.env.EXPRESS_API_SCOPE || DEFAULT_SCOPE, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`IMS token request failed ${response.status}: ${text}`); + } + + const data = await response.json(); + cachedToken = { + accessToken: data.access_token, + expiresAt: Date.now() + Number(data.expires_in), + }; + return cachedToken.accessToken; +} + +async function buildAuthHeaders() { + const token = await getAccessToken(); + return { + Authorization: `Bearer ${token}`, + 'X-API-KEY': process.env.EXPRESS_CLIENT_ID, + }; +} + +module.exports = { getAccessToken, buildAuthHeaders }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test expressAuth.test.js` +Expected: PASS — 5 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add expressAuth.js expressAuth.test.js +git commit -m "feat: add Adobe IMS token fetch/cache module" +``` + +--- + +## Task 2: `imageStore.js` — real catalog-backed image store + +**Files:** +- Create: `data/express-templates.json` +- Modify: `imageStore.js` (full rewrite) +- Modify: `imageStore.test.js` (full rewrite) + +**Interfaces:** +- Consumes: env var `EXPRESS_TEMPLATES_FILE` (optional, defaults to `data/express-templates.json` relative to this file). +- Produces: `getTrackedImages(phoneNumber) -> Array<{ id, name, docId, currentEdits }>`, `findTrackedImage(phoneNumber, imageId) -> object | undefined`, `recordEdits(phoneNumber, imageId, newEdits) -> object` (merges and returns the new `currentEdits`). + +- [ ] **Step 1: Write the failing tests** + +Create `imageStore.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +test('getTrackedImages reads the catalog from EXPRESS_TEMPLATES_FILE', () => { + writeFixtureCatalog([ + { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }, + { id: 'img_2', name: 'Summer Sale Flyer', docId: 'urn:doc:2' }, + ]); + + const images = getTrackedImages('phone-1'); + + assert.equal(images.length, 2); + assert.deepEqual(images[0], { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1', currentEdits: {} }); +}); + +test('getTrackedImages returns an empty list when the catalog file is missing', () => { + process.env.EXPRESS_TEMPLATES_FILE = path.join(os.tmpdir(), 'does-not-exist.json'); + + const images = getTrackedImages('phone-2'); + + assert.deepEqual(images, []); +}); + +test('findTrackedImage returns the matching image by id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-3', 'img_3'); + + assert.equal(image.name, 'Croma Earbuds'); +}); + +test('findTrackedImage returns undefined for an unknown id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-4', 'img_nope'); + + assert.equal(image, undefined); +}); + +test('recordEdits merges edits per phone number and image id, visible via findTrackedImage', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-5', 'img_1', { headline: 'Flash Sale' }); + recordEdits('phone-5', 'img_1', { discount_text: '70%' }); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { headline: 'Flash Sale', discount_text: '70%' }); +}); + +test('recordEdits keeps edits independent per phone number', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-6', 'img_1', { headline: 'Only for phone-6' }); + + const otherPhoneImage = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(otherPhoneImage.currentEdits, {}); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test imageStore.test.js` +Expected: FAIL — assertions fail against the old hardcoded `SEED_IMAGES` behavior (module exists but returns the wrong shape/values) + +- [ ] **Step 3: Write the implementation** + +Replace the contents of `imageStore.js`: + +```js +const fs = require('node:fs'); +const path = require('node:path'); + +function catalogPath() { + return process.env.EXPRESS_TEMPLATES_FILE || path.join(__dirname, 'data', 'express-templates.json'); +} + +function loadCatalog() { + try { + const raw = fs.readFileSync(catalogPath(), 'utf8'); + return JSON.parse(raw); + } catch (err) { + console.error('[imageStore] failed to read catalog', { path: catalogPath(), message: err.message }); + return []; + } +} + +const conversationEdits = new Map(); + +function getTrackedImages(phoneNumber) { + return loadCatalog().map((entry) => ({ + ...entry, + currentEdits: conversationEdits.get(`${phoneNumber}:${entry.id}`) || {}, + })); +} + +function findTrackedImage(phoneNumber, imageId) { + return getTrackedImages(phoneNumber).find((image) => image.id === imageId); +} + +function recordEdits(phoneNumber, imageId, newEdits) { + const key = `${phoneNumber}:${imageId}`; + const merged = { ...(conversationEdits.get(key) || {}), ...newEdits }; + conversationEdits.set(key, merged); + return merged; +} + +module.exports = { getTrackedImages, findTrackedImage, recordEdits }; +``` + +- [ ] **Step 4: Create the real catalog file** + +Create `data/express-templates.json` with the one confirmed real entry (the Diwali/Summer entries from the old mock had no real Express document behind them, so they're not carried forward as fake data — add more entries here once the UI app provides their real `docId`s): + +```json +[ + { "id": "img_1", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" } +] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `node --test imageStore.test.js` +Expected: PASS — 6 tests passing + +- [ ] **Step 6: Commit** + +```bash +git add imageStore.js imageStore.test.js data/express-templates.json +git commit -m "feat: read the shared docID catalog instead of hardcoded seed images" +``` + +--- + +## Task 3: `expressApi.js` — real Adobe Express HTTP calls + +**Files:** +- Modify: `expressApi.js` (full rewrite) +- Modify: `expressApi.test.js` (full rewrite) + +**Interfaces:** +- Consumes: `buildAuthHeaders` from `./expressAuth` (Task 1); env vars `EXPRESS_API_BASE_URL`, `EXPRESS_STATUS_POLL_INTERVAL_MS`, `EXPRESS_STATUS_POLL_TIMEOUT_MS` (all optional). +- Produces: `getTaggedDocument(docId) -> Promise`, `generateVariation(docId, tagMappings, pages, preferredDocumentName) -> Promise<{ jobId, statusUrl }>`, `getJobStatus(jobId) -> Promise`, `pollJobStatus(jobId, { intervalMs?, timeoutMs? }) -> Promise`, `collectTaggedElements(taggedDocument) -> Array<{ name, type, value?, pageNumber }>`, `formatAllowedEdits(name, elements) -> string`, `pagesForEdits(elements, editKeys) -> string`, `buildPreferredDocumentName(baseName) -> string`. + +- [ ] **Step 1: Write the failing tests** + +Create `expressApi.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +} = require('./expressApi'); + +const originalFetch = global.fetch; + +function stubFetch(handlers) { + global.fetch = async (url, options) => { + if (url.includes('ims-na1.adobelogin.com')) { + return { ok: true, json: async () => ({ access_token: 'tok-test', expires_in: 86400000 }) }; + } + for (const [pattern, handler] of handlers) { + if (pattern.test(url)) return handler(url, options); + } + throw new Error(`Unexpected fetch call: ${url}`); + }; +} + +test('getTaggedDocument fetches and returns the tagged document', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async (url, options) => { + assert.match(url, /\/beta\/tagged-documents\/urn%3Aaaid%3Asc%3AAP%3Aabc$/); + assert.equal(options.headers.Authorization, 'Bearer tok-test'); + assert.equal(options.headers['X-API-KEY'], 'client-1'); + return { ok: true, json: async () => ({ name: 'Croma2-Doc', id: 'urn:aaid:sc:AP:abc', documentPages: [] }) }; + }], + ]); + + const doc = await getTaggedDocument('urn:aaid:sc:AP:abc'); + + assert.equal(doc.name, 'Croma2-Doc'); + global.fetch = originalFetch; +}); + +test('getTaggedDocument throws with the status and body on a non-ok response', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async () => ({ ok: false, status: 404, text: async () => 'not found' })], + ]); + + await assert.rejects(() => getTaggedDocument('urn:missing'), /404/); + global.fetch = originalFetch; +}); + +test('generateVariation posts the right body and returns jobId/statusUrl', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/generate-variation$/, async (url, options) => { + assert.equal(options.method, 'POST'); + const body = JSON.parse(options.body); + assert.deepEqual(body, { + id: 'urn:doc:1', + variationDetails: { + pages: '1', + preferredDocumentName: 'Croma Earbuds-edit-123', + tagMappings: { cta: '20% off' }, + }, + }); + return { ok: true, json: async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }) }; + }], + ]); + + const result = await generateVariation('urn:doc:1', { cta: '20% off' }, '1', 'Croma Earbuds-edit-123'); + + assert.deepEqual(result, { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); + global.fetch = originalFetch; +}); + +test('getJobStatus returns the parsed status response', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/status\//, async () => ({ + ok: true, + json: async () => ({ jobId: 'job-1', status: 'succeeded', document: { name: 'GD2.express', id: 'urn:doc:2', thumbnailUrl: 'https://example.com/thumb.png' } }), + })], + ]); + + const result = await getJobStatus('job-1'); + + assert.equal(result.status, 'succeeded'); + assert.equal(result.document.thumbnailUrl, 'https://example.com/thumb.png'); + global.fetch = originalFetch; +}); + +test('pollJobStatus resolves once status is succeeded', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + let calls = 0; + stubFetch([ + [/\/status\//, async () => { + calls += 1; + const status = calls < 2 ? 'running' : 'succeeded'; + return { + ok: true, + json: async () => ({ + jobId: 'job-2', + status, + document: status === 'succeeded' ? { thumbnailUrl: 'https://example.com/thumb2.png' } : undefined, + }), + }; + }], + ]); + + const result = await pollJobStatus('job-2', { intervalMs: 1, timeoutMs: 1000 }); + + assert.equal(result.status, 'succeeded'); + assert.equal(calls, 2); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws when status is failed', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/status\//, async () => ({ ok: true, json: async () => ({ jobId: 'job-3', status: 'failed' }) })], + ]); + + await assert.rejects(() => pollJobStatus('job-3', { intervalMs: 1, timeoutMs: 1000 }), /failed/); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws once the timeout elapses without succeeding', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/status\//, async () => ({ ok: true, json: async () => ({ jobId: 'job-4', status: 'running' }) })], + ]); + + await assert.rejects(() => pollJobStatus('job-4', { intervalMs: 5, timeoutMs: 20 }), /timed out/); + global.fetch = originalFetch; +}); + +test('collectTaggedElements flattens taggedElements across all pages with pageNumber attached', () => { + const doc = { + documentPages: [ + { pageNumber: 1, taggedElements: [{ name: 'heading', type: 'text', value: 'Hi' }] }, + { pageNumber: 2, taggedElements: [{ name: 'footer', type: 'text', value: 'Bye' }] }, + ], + }; + + const elements = collectTaggedElements(doc); + + assert.deepEqual(elements, [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'footer', type: 'text', value: 'Bye', pageNumber: 2 }, + ]); +}); + +test('formatAllowedEdits lists text elements with their current value and non-text elements with just their type', () => { + const elements = [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'logo', type: 'image', pageNumber: 1 }, + ]; + + const message = formatAllowedEdits('Croma Earbuds', elements); + + assert.match(message, /Edits allowed on "Croma Earbuds":/); + assert.match(message, /- heading: currently "Hi"/); + assert.match(message, /- logo \(image\)/); +}); + +test('pagesForEdits returns the sorted, comma-joined page numbers containing the edited fields', () => { + const elements = [ + { name: 'heading', pageNumber: 2 }, + { name: 'cta', pageNumber: 1 }, + { name: 'footer', pageNumber: 1 }, + ]; + + assert.equal(pagesForEdits(elements, ['cta']), '1'); + assert.equal(pagesForEdits(elements, ['cta', 'heading']), '1,2'); +}); + +test('buildPreferredDocumentName appends a timestamp suffix to the base name', () => { + const name = buildPreferredDocumentName('Croma Earbuds'); + assert.match(name, /^Croma Earbuds-edit-\d+$/); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test expressApi.test.js` +Expected: FAIL — old mocked `getTemplateInfo`/`applyEdit` exports don't match the new functions being imported + +- [ ] **Step 3: Write the implementation** + +Replace the contents of `expressApi.js`: + +```js +const { buildAuthHeaders } = require('./expressAuth'); + +function apiBaseUrl() { + return process.env.EXPRESS_API_BASE_URL || 'https://express-api.adobe.io'; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getTaggedDocument(docId) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/tagged-documents/${encodeURIComponent(docId)}`, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getTaggedDocument failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function generateVariation(docId, tagMappings, pages, preferredDocumentName) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/generate-variation`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: docId, + variationDetails: { pages, preferredDocumentName, tagMappings }, + }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`generateVariation failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function getJobStatus(jobId) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/status/${encodeURIComponent(jobId)}`, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getJobStatus failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function pollJobStatus(jobId, { intervalMs, timeoutMs } = {}) { + const interval = intervalMs ?? Number(process.env.EXPRESS_STATUS_POLL_INTERVAL_MS || 2000); + const timeout = timeoutMs ?? Number(process.env.EXPRESS_STATUS_POLL_TIMEOUT_MS || 60000); + const deadline = Date.now() + timeout; + + for (;;) { + const result = await getJobStatus(jobId); + if (result.status === 'succeeded') return result; + if (result.status === 'failed') throw new Error(`Express job ${jobId} failed`); + if (Date.now() >= deadline) throw new Error(`Express job ${jobId} timed out after ${timeout}ms`); + await sleep(interval); + } +} + +function collectTaggedElements(taggedDocument) { + const elements = []; + for (const page of taggedDocument.documentPages || []) { + for (const element of page.taggedElements || []) { + elements.push({ ...element, pageNumber: page.pageNumber }); + } + } + return elements; +} + +function formatAllowedEdits(name, elements) { + const lines = elements.map((element) => + element.type === 'text' + ? `- ${element.name}: currently "${element.value}"` + : `- ${element.name} (${element.type})` + ); + const example = elements[0]?.name || 'a field'; + return `Edits allowed on "${name}":\n${lines.join('\n')}\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; +} + +function pagesForEdits(elements, editKeys) { + const pageNumbers = new Set( + elements.filter((element) => editKeys.includes(element.name)).map((element) => element.pageNumber) + ); + return [...pageNumbers].sort((a, b) => a - b).join(','); +} + +function buildPreferredDocumentName(baseName) { + return `${baseName}-edit-${Date.now()}`; +} + +module.exports = { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test expressApi.test.js` +Expected: PASS — 11 tests passing + +- [ ] **Step 5: Commit** + +```bash +git add expressApi.js expressApi.test.js +git commit -m "feat: replace mocked Express API with real tagged-documents/generate-variation/status calls" +``` + +--- + +## Task 4: `actions.js` — wire the real edit flow + +**Files:** +- Modify: `actions.js` (full rewrite) +- Modify: `actions.test.js` (full rewrite) + +**Interfaces:** +- Consumes: `getTrackedImages`, `findTrackedImage`, `recordEdits` from `./imageStore` (Task 2); the `expressApi` module as a **namespace import** (`const expressApi = require('./expressApi')`, not destructured) from Task 3, specifically so tests can stub `expressApi.getTaggedDocument`/`generateVariation`/`pollJobStatus` directly without touching `global.fetch`. +- Produces: `actionListCampaignGraphics() -> Promise` (unchanged), `actionCheckAllowedEdits(phoneNumber, imageId) -> Promise`, `actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) -> Promise`, `actionGenerateBulkGraphics(filename) -> Promise` (unchanged). + +- [ ] **Step 1: Write the failing tests** + +Create `actions.test.js`: + +```js +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const expressApi = require('./expressApi'); +const { findTrackedImage } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +const SAMPLE_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'heading', type: 'text', value: 'The X-Phone Pro is here!' }, + { name: 'cta', type: 'text', value: 'Available at our store starting 15 Aug 20XX.' }, + ], + }, + ], +}; + +test('actionCheckAllowedEdits lists the tagged elements for a known image', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async (docId) => { + assert.equal(docId, 'urn:doc:1'); + return SAMPLE_ELEMENTS_DOC; + }; + + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + + assert.match(reply, /Croma Earbuds/); + assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply, /cta: currently/); +}); + +test('actionCheckAllowedEdits reports unknown images without throwing', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + + const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + + assert.match(reply, /couldn't find that image/); +}); + +test('actionCheckAllowedEdits returns a friendly message when the Express API call fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => { + throw new Error('getTaggedDocument failed 500: boom'); + }; + + const reply = await actionCheckAllowedEdits('phone-3', 'img_1'); + + assert.match(reply, /couldn't check the allowed edits/); +}); + +test('actionEditGraphic rejects edits outside the tagged elements and makes no generate call', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { + throw new Error('should not be called'); + }; + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('actionEditGraphic applies an allowed edit end-to-end: generates, polls, sends the thumbnail, and records the edit', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings, pages, preferredDocumentName) => { + assert.equal(docId, 'urn:doc:1'); + assert.deepEqual(tagMappings, { cta: '20% off' }); + assert.equal(pages, '1'); + assert.match(preferredDocumentName, /^Croma Earbuds-edit-\d+$/); + return { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }; + }; + expressApi.pollJobStatus = async (jobId) => { + assert.equal(jobId, 'job-1'); + return { status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }; + }; + + const sentCalls = []; + const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; + + const reply = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /Updated "Croma Earbuds"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-5'); + assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +}); + +test('actionEditGraphic returns a friendly message and does not record the edit when generation fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; + + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /something went wrong generating/); + assert.equal(sendImageCalled, false); + + const image = findTrackedImage('phone-6', 'img_1'); + assert.deepEqual(image.currentEdits, {}); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test actions.test.js` +Expected: FAIL — old `actions.js` still validates against `getTemplateInfo`/hardcoded layers and calls `metaUpload`, not matching these assertions + +- [ ] **Step 3: Write the implementation** + +Replace the contents of `actions.js`: + +```js +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); +const expressApi = require('./expressApi'); + +function formatUnknownImageMessage(phoneNumber) { + const images = getTrackedImages(phoneNumber); + const list = images.map((image) => `- ${image.name}`).join('\n'); + return `I couldn't find that image. Here's what I have:\n${list}`; +} + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Diwali Offer Banner\n2. Summer Sale Flyer\n3. Croma Earbuds'; +} + +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + try { + const doc = await expressApi.getTaggedDocument(image.docId); + const elements = expressApi.collectTaggedElements(doc); + return expressApi.formatAllowedEdits(image.name, elements); + } catch (err) { + console.error('[actionCheckAllowedEdits] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; + } +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + let elements; + try { + const doc = await expressApi.getTaggedDocument(image.docId); + elements = expressApi.collectTaggedElements(doc); + } catch (err) { + console.error('[actionEditGraphic] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; + } + + const allowedNames = elements.map((element) => element.name); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); + + if (disallowedKeys.length > 0) { + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${expressApi.formatAllowedEdits(image.name, elements)}`; + } + + const mergedEdits = { ...image.currentEdits, ...edits }; + const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); + const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); + + let thumbnailUrl; + try { + const { jobId } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); + const result = await expressApi.pollJobStatus(jobId); + thumbnailUrl = result.document.thumbnailUrl; + } catch (err) { + console.error('[actionEditGraphic] generate/poll error', { docId: image.docId, message: err.message }); + return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; + } + + recordEdits(phoneNumber, imageId, edits); + await sendImage(phoneNumber, thumbnailUrl); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + return `Updated "${image.name}":\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +}; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `node --test actions.test.js` +Expected: PASS — 6 tests passing + +- [ ] **Step 5: Run the full test suite** + +Run: `node --test` +Expected: PASS — all tests across `expressAuth.test.js`, `imageStore.test.js`, `expressApi.test.js`, `actions.test.js`, `metaUpload.test.js` passing + +- [ ] **Step 6: Commit** + +```bash +git add actions.js actions.test.js +git commit -m "feat: wire actions.js to the real Express API and drop mocked edit validation" +``` + +--- + +## Task 5: `app.js` webhook logging + `render.yaml` env vars + +**Files:** +- Modify: `app.js:197-202` (incoming message loop) +- Modify: `render.yaml` (envVars list) + +**Interfaces:** +- Consumes: nothing new — pure logging addition, no new imports. +- Produces: nothing new — verified manually (webhook route, no unit test, matching the existing pattern for `app.js`'s WhatsApp/webhook wiring). + +- [ ] **Step 1: Add labeled webhook logging** + +In `app.js`, inside the `for (const message of messages) {` loop in the `app.post('/')` handler, replace: + +```js + for (const message of messages) { + const userText = message?.text?.body; + if (!userText) continue; +``` + +with: + +```js + for (const message of messages) { + if (message.context) console.log('[webhook] message.context:', JSON.stringify(message.context)); + if (message.referral) console.log('[webhook] message.referral:', JSON.stringify(message.referral)); + if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); + + const userText = message?.text?.body; + if (!userText) continue; +``` + +- [ ] **Step 2: Add the new env vars to `render.yaml`** + +In `render.yaml`, add to the `envVars` list (after the existing `OPENAI_MODEL` entry): + +```yaml + - key: EXPRESS_CLIENT_ID + sync: false + - key: EXPRESS_CLIENT_SECRET + sync: false + - key: EXPRESS_API_SCOPE + sync: false + - key: EXPRESS_IMS_TOKEN_URL + sync: false + - key: EXPRESS_API_BASE_URL + sync: false + - key: EXPRESS_TEMPLATES_FILE + sync: false + - key: EXPRESS_STATUS_POLL_INTERVAL_MS + sync: false + - key: EXPRESS_STATUS_POLL_TIMEOUT_MS + sync: false +``` + +- [ ] **Step 3: Verify the file is syntactically valid** + +Run: `node --check app.js` +Expected: no output (silent success) + +- [ ] **Step 4: Verify the server still boots and the webhook-verification route still works** + +Run: + +```bash +VERIFY_TOKEN=test WHATSAPP_PHONE_NUMBER_ID=123 WHATSAPP_TOKEN=test OPENAI_API_KEY=test EXPRESS_CLIENT_ID=test EXPRESS_CLIENT_SECRET=test node app.js & +SERVER_PID=$! +sleep 1 +curl -s "http://localhost:3000/?hub.mode=subscribe&hub.verify_token=test&hub.challenge=hello123" +kill $SERVER_PID +``` + +Expected: `hello123` printed by curl, followed by `WEBHOOK VERIFIED` in the server's stdout before it's killed. + +- [ ] **Step 5: Run the full test suite once more to confirm nothing broke** + +Run: `node --test` +Expected: PASS — all tests passing (same count as end of Task 4) + +- [ ] **Step 6: Commit** + +```bash +git add app.js render.yaml +git commit -m "feat: log webhook metadata fields and declare Express API env vars" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** IMS auth (Task 1), real `tagged-documents`/`generate-variation`/`status` calls (Task 3), shared `id`/`name`/`docId` catalog replacing hardcoded seed images (Task 2), simplified allowed-edits messaging and real-field validation (Tasks 3–4), generate→poll→send flow with `thumbnailUrl` sent directly (Task 4), `metaUpload.js` left in place and unused (Task 4, no import added), webhook metadata logging for docID investigation (Task 5), env var declarations (Task 5) — all covered. `actionListCampaignGraphics`/`actionGenerateBulkGraphics` and any new logic on `context`/`referral`/`image` fields are explicitly out of scope per the spec and untouched beyond logging. +- **Placeholder scan:** no TBDs; the two `// TODO: fetch from campaign API` / `// TODO: parse CSV/Excel...` comments are carried over unchanged from existing code (explicitly out of scope) and are intentional. +- **Type consistency:** `image.docId` (not `templateId`) used consistently across `imageStore.js` (Task 2), `expressApi.js` functions (Task 3), and `actions.js` (Task 4). `currentEdits` shape (`{ [tagName]: value }`) consistent between `imageStore.js`'s `recordEdits`/`getTrackedImages` and `actions.js`'s `mergedEdits` construction. `sendImage(to, link)` signature unchanged from the existing `app.js` implementation, matching its call site in Task 4. `expressApi` is a namespace import in `actions.js` (not destructured) specifically so `actions.test.js` can stub individual functions — noted in Task 4's Interfaces block so this doesn't look like an inconsistency with Task 3's plain destructure inside `expressApi.js` itself (that destructure is fine since `expressApi.test.js` stubs `global.fetch`, not `expressAuth`'s exports). diff --git a/docs/superpowers/specs/2026-07-13-express-edit-flow-design.md b/docs/superpowers/specs/2026-07-13-express-edit-flow-design.md new file mode 100644 index 0000000000..272d63626f --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-express-edit-flow-design.md @@ -0,0 +1,132 @@ +# Design: Image edit flow (Adobe Express, mocked) + +## Goal + +Support this WhatsApp conversation flow: + +1. User has previously been sent a message containing an image (graphic). +2. User asks what edits are allowed on one of those images. +3. System looks up which template the image used and which layers are unlocked (via Adobe Express API). +4. User asks for a specific edit. +5. System checks whether the requested edit is one of the allowed edits. +6. System calls the Adobe Express API to apply the edit. +7. System uploads the resulting image to Meta and gets back a URL. +8. System sends a new WhatsApp message with the updated image. + +Adobe Express API calls are out of scope for this iteration — all Express/Meta-upload calls are placeholder functions returning mock values, structured so they can be swapped for real implementations later without changing the surrounding flow. + +Actually sending the *initial* graphic image (step 1) is out of scope. Instead, a small set of mock "sent images" is pre-seeded at server startup for any phone number, so the rest of the flow has something to operate against. + +## Data model + +New in-memory store, `imageStore.js`, following the same per-phone-number Map pattern as the existing `conversationHistory`: + +```js +// Map> +// Seeded lazily on first access for a phone number — same 3 mock entries every time. +{ + id: 'img_1', // stable id GPT references in tool calls + name: 'Diwali Offer Banner', // human-readable, shown to GPT so it can match user text + templateId: 'tpl_diwali', // Express template id + currentEdits: {}, // accumulates applied edits across calls +} +``` + +Three seed entries (mirroring the existing `list_campaign_graphics` mock names): Diwali Offer Banner / Summer Sale Flyer / New Arrival Poster, each with a distinct mock `templateId`. + +`getTrackedImages(phoneNumber)` returns (seeding if needed) the array; `findTrackedImage(phoneNumber, imageId)` returns one entry or `undefined`. + +## GPT tool changes + +GPT is responsible for figuring out *which* tracked image a user's message refers to (per your choice of "GPT infers from text"), so the system prompt must include the phone number's tracked images (id + name) on every call to `decideAction`. Both relevant tools gain an `image_id` parameter: + +```js +check_allowed_edits({ image_id }) +edit_graphic({ image_id, edits }) +``` + +`image_id` is required on both. Tool descriptions instruct GPT to pick the id from the list provided in the system prompt, matching the user's description (e.g. "the Diwali one") to the closest tracked image name. + +System prompt gains a section like: + +``` +Images previously sent to this user (reference by id): +- img_1: Diwali Offer Banner +- img_2: Summer Sale Flyer +- img_3: New Arrival Poster +``` + +## Mock Adobe Express API (`expressApi.js`) + +Placeholder module, no network calls: + +```js +function getTemplateInfo(templateId) { + // Mock: returns different unlocked layers per template so behavior isn't uniform. + // e.g. tpl_diwali -> ['discount_text', 'headline', 'background_color'] + // tpl_summer -> ['headline', 'font_color'] + // tpl_newarrival -> ['headline'] +} + +function applyEdit(templateId, currentEdits, newEdits) { + // Mock: merges edits, returns a fake rendered-image reference + // e.g. { renderedImageUrl: 'https://mock-express.local/render/tpl_diwali?rev=3' } +} +``` + +Both are synchronous or trivially async (`Promise.resolve`) mocks — no real HTTP calls yet. + +## Mock Meta upload + +Added alongside the existing WhatsApp helpers in app.js (or a small `whatsapp.js` if we split further — not required for this scope): + +```js +async function uploadImageToMeta(renderedImageUrl) { + // Mock: pretend to upload to Meta's media endpoint, return a fake CDN url + // e.g. `https://mock-meta-cdn.local/media/.png` +} +``` + +`sendImage(to, link)` already exists and takes a link — the mock URL from `uploadImageToMeta` is passed straight into it. + +## Action handlers + +### `actionCheckAllowedEdits(phoneNumber, imageId)` + +1. `findTrackedImage(phoneNumber, imageId)` — if not found, return an error message ("I couldn't find that image — here's what I have: ..."). +2. `getTemplateInfo(image.templateId)` → `unlockedLayers`. +3. Return a formatted list of allowed edits for that specific image (not the generic hardcoded string currently returned). + +### `actionEditGraphic(phoneNumber, imageId, edits)` + +1. `findTrackedImage(phoneNumber, imageId)` — same not-found handling as above. +2. `getTemplateInfo(image.templateId)` → `unlockedLayers`. +3. Validate: every key in `edits` must be in `unlockedLayers`. If any key isn't allowed, return a message naming the rejected field(s) and listing the actually-allowed fields — **do not call Express or Meta**. This is the "system checks if this is one of the allowed edits" step, done in code, not by GPT. +4. `applyEdit(templateId, image.currentEdits, edits)` → mock render; merge `edits` into `image.currentEdits`. +5. `uploadImageToMeta(renderedImageUrl)` → mock URL. +6. `sendImage(phoneNumber, mockUrl)` — new WhatsApp message with the updated image. +7. Return a short confirming text (e.g. "Updated: discount_text → 70%") which is sent as a follow-up text message via the existing `sendText` call in the webhook handler. + +## Webhook wiring + +In `app.post('/')`, the `check_allowed_edits` and `edit_graphic` cases pass `args.image_id` (and `args.edits`) through to the updated action handlers. No change to the outer loop structure. + +## Error handling + +- Unknown `image_id` (GPT hallucination): friendly text response listing currently tracked images, no crash. +- Disallowed edit field(s): friendly text response naming what's not allowed and what is, no Express/Meta calls made. +- Mock functions never throw — real error handling (network failures, Express API errors) is deferred to when real API calls replace the mocks. + +## Testing + +Manual verification only for this mocked iteration (no live Express/Meta credentials exist yet): +- Simulate incoming webhook payloads for: "what edits can I make to the summer flyer", followed by "change the headline to Flash Sale" — confirm the reply lists the right mock unlocked layers and then confirms the edit + attempts a mock image send. +- Simulate an edit request for a field not in `unlockedLayers` — confirm it's rejected with the allowed-fields message and no image is sent. +- Simulate a reference to a nonexistent image — confirm the not-found message. + +## Out of scope + +- Real Adobe Express API integration. +- Real Meta media upload. +- Actually sending the initial graphic image that seeds the flow (images are pre-seeded in memory instead). +- Persistence across server restarts (in-memory only, matches existing `conversationHistory` pattern). diff --git a/docs/superpowers/specs/2026-07-13-global-fixed-edit-response-design.md b/docs/superpowers/specs/2026-07-13-global-fixed-edit-response-design.md new file mode 100644 index 0000000000..afcdc7cabb --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-global-fixed-edit-response-design.md @@ -0,0 +1,52 @@ +# Design: Global fixed responses for edit-graphic and check-allowed-edits + +Amends [2026-07-13-express-edit-flow-design.md](./2026-07-13-express-edit-flow-design.md). + +## Goal + +Simplify two of the existing edit-flow actions in `actions.js` so their outputs are fixed, regardless of which image/template the request is about: + +1. **`edit_graphic`** — after any successful edit, always send the same hardcoded image (`https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated`) as a new WhatsApp image message to the requesting user. No more per-template mock render or Meta upload. +2. **`check_allowed_edits`** — always reply with the fixed text `Price, Partner logo, Partner's Address, product image`, regardless of which image is asked about. + +As a consequence, the "is this edit key allowed for this template" validation in `actionEditGraphic` is removed: any edit key is now accepted for any image. + +## Scope + +Only `actions.js` changes. `expressApi.js`, `imageStore.js`, `metaUpload.js`, and `app.js` are untouched — `expressApi.js` and `metaUpload.js` become unused by application code (no longer called from `actions.js`) but are left in place, along with their existing tests, as placeholders for a future real Adobe Express / Meta upload integration. + +## Changes to `actions.js` + +Two new constants: + +```js +const EDITED_IMAGE_URL = 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'; +const ALLOWED_EDITS_TEXT = "Price, Partner logo, Partner's Address, product image"; +``` + +`actionCheckAllowedEdits(phoneNumber, imageId)`: +- Keeps the unknown-image guard (`formatUnknownImageMessage`). +- For any known image, returns `ALLOWED_EDITS_TEXT` unconditionally — no more per-template `getTemplateInfo` lookup. + +`actionEditGraphic(phoneNumber, imageId, edits, { sendImage })`: +- Keeps the unknown-image guard. +- Drops the disallowed-key validation entirely (no `getTemplateInfo` call, no rejection path). +- Merges `edits` into `image.currentEdits` directly (`{ ...image.currentEdits, ...edits }`) instead of calling `expressApi.applyEdit` — the rendered URL it used to return is no longer needed since we always send `EDITED_IMAGE_URL`. +- Sends `EDITED_IMAGE_URL` via `sendImage` (no more `metaUpload.uploadImageToMeta` call, since this URL is already publicly hosted). +- Returns the same `Updated "":\n` confirmation text as before. + +Unused imports (`getTemplateInfo`, `applyEdit` from `expressApi.js`; `uploadImageToMeta` from `metaUpload.js`) are removed from `actions.js`. + +## Error handling + +Unchanged from the existing flow: an unknown `image_id` (GPT hallucination) still gets the friendly "I couldn't find that image" message in both actions, with no crash. There is no other error path left in `actionEditGraphic` now that key validation is removed. + +## Testing + +Update `actions.test.js`: +- `actionCheckAllowedEdits` tests for known images (Diwali, Croma) now assert the reply equals `ALLOWED_EDITS_TEXT`, for any tracked image — not a per-template layer list. +- The "rejects edits outside the unlocked layers" test is replaced with a test confirming a previously-disallowed key (e.g. `background_color` on the Croma image) is now accepted and sends the image. +- The edit tests (Croma image, Summer Sale Flyer image) both assert `sendImage` is called with `EDITED_IMAGE_URL`, for every template — not just Croma. +- The "remembers the edit" assertion (`image.currentEdits`) is unchanged. + +`expressApi.test.js` and `metaUpload.test.js` are untouched — those modules' own behavior isn't changing, only their (lack of) callers. diff --git a/docs/superpowers/specs/2026-07-16-real-express-api-integration-design.md b/docs/superpowers/specs/2026-07-16-real-express-api-integration-design.md new file mode 100644 index 0000000000..5505b2d7f0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-real-express-api-integration-design.md @@ -0,0 +1,188 @@ +# Design: Real Adobe Express API integration for the image-edit flow + +Amends [2026-07-13-express-edit-flow-design.md](./2026-07-13-express-edit-flow-design.md) and [2026-07-13-global-fixed-edit-response-design.md](./2026-07-13-global-fixed-edit-response-design.md), replacing the mocked/fixed behavior those introduced with real Adobe Express API calls. + +## Goal + +Replace all hardcoding and mocked responses in the image-edit flow with real calls to the Adobe Express API: + +1. `GET /beta/tagged-documents/` to discover what's editable on a graphic and show the user a simplified list. +2. `POST /beta/generate-variation` to apply the user's requested edits. +3. `GET /status/` to poll until the variation is ready. +4. Send the resulting `thumbnailUrl` back to the user as a new WhatsApp image message. + +The identity of *which* Express document a tracked image maps to comes from a JSON catalog shared with the separate UI repo (out of scope for this change), not from anything invented in this backend. + +## Shared catalog: `data/express-templates.json` + +Committed to this repo (not gitignored), maintained in step with an equivalent file in the UI repo: + +```json +[ + { "id": "img_1", "name": "Diwali Offer Banner", "docId": "urn:aaid:sc:AP:..." }, + { "id": "img_2", "name": "Summer Sale Flyer", "docId": "urn:aaid:sc:AP:..." }, + { "id": "img_3", "name": "Croma Earbuds", "docId": "urn:aaid:sc:AP:aaed427c-b4e4-55e4-b924-74d375f91684" } +] +``` + +- Flat array, `id`/`name`/`docId` only — no `phoneNumber`. It's a global catalog of known templates/images, not a per-customer sent-images log (the UI app's `sendWhatsAppTemplateMessage` already carries `docId` alongside `templateName` on its own send calls; this file is the read side this backend needs). +- Path is overridable via `EXPRESS_TEMPLATES_FILE` env var, defaulting to `data/express-templates.json`. +- Keeping both repos' copies of this file in sync (git submodule, manual copy, CI step, etc.) is out of scope for this change — flagged, not solved, here. + +## New module: `expressAuth.js` + +```js +async function getAccessToken() // returns a cached IMS access token, refreshing near expiry +async function buildAuthHeaders() // -> { Authorization: 'Bearer ', 'X-API-KEY': } +``` + +- `POST https://ims-na1.adobelogin.com/ims/token/v3` (overridable via `EXPRESS_IMS_TOKEN_URL`), `grant_type=client_credentials`, `client_id`/`client_secret` from `EXPRESS_CLIENT_ID`/`EXPRESS_CLIENT_SECRET`, `scope` from `EXPRESS_API_SCOPE` (default `ee.express_api,openid,AdobeID,read_organizations,additional_info.projectedProductContext`, taken from a decoded sample token). +- `X-API-KEY` is `EXPRESS_CLIENT_ID` (confirmed identical to the sample token's `client_id` claim). +- Token cached in memory with its expiry; refetched once within 60s of expiry. +- **Assumption to verify on first real call:** Adobe IMS v3 token responses have historically returned `expires_in` in **milliseconds**, not seconds (unlike v2). This module treats `expires_in` as milliseconds directly (`expiresAt = Date.now() + Number(expires_in)`). If the real response turns out to be in seconds, this is a one-line fix isolated to this function. + +## Rewritten module: `expressApi.js` + +No more `TEMPLATE_LAYERS`/mock render. Real HTTP calls against `EXPRESS_API_BASE_URL` (default `https://express-api.adobe.io`), using `expressAuth.buildAuthHeaders()`: + +```js +async function getTaggedDocument(docId) +// GET /beta/tagged-documents/ +// -> { name, id, documentPages: [{ pageNumber, taggedElements: [{ name, type, value?, position, size }] }] } + +async function generateVariation(docId, tagMappings, pages, preferredDocumentName) +// POST /beta/generate-variation +// body: { id: docId, variationDetails: { pages, preferredDocumentName, tagMappings } } +// -> { jobId, statusUrl } + +async function getJobStatus(jobId) +// GET /status/ +// -> { jobId, status, document?: { name, id, thumbnailUrl } } + +async function pollJobStatus(jobId, { intervalMs, timeoutMs } = {}) +// Polls getJobStatus every intervalMs (default EXPRESS_STATUS_POLL_INTERVAL_MS=2000) +// until status === 'succeeded' (returns the full result) or 'failed' (throws), +// or until timeoutMs elapses (default EXPRESS_STATUS_POLL_TIMEOUT_MS=60000, throws). +``` + +Non-2xx responses from any of the three GET/POST calls throw with the HTTP status and response body included in the error message, for logging by the caller. + +### Helpers (co-located in `expressApi.js`) + +```js +function collectTaggedElements(taggedDocument) +// Flattens documentPages[].taggedElements[] into one array, each tagged with its pageNumber. + +function formatAllowedEdits(name, elements) +// Simplified user-facing text, e.g.: +// Edits allowed on "Croma Earbuds": +// - heading: currently "The X-Phone Pro is here!" +// - cta: currently "Available at our store starting 15 Aug 20XX." +// Tell me what you'd like to change and to what, e.g. "change cta to ...". +// Non-text element types are listed as "- ()" without a current value. + +function pagesForEdits(elements, editKeys) +// -> comma-joined, ascending page numbers whose taggedElements include any of editKeys, e.g. "1" or "1,2". + +function buildPreferredDocumentName(baseName) +// -> `${baseName}-edit-${Date.now()}` +``` + +## Rewritten module: `imageStore.js` + +```js +function getTrackedImages(phoneNumber) +// Reads data/express-templates.json fresh on every call (no caching of the catalog itself), +// merges in this conversation's accumulated tagMappings. +// -> Array<{ id, name, docId, currentEdits }> + +function findTrackedImage(phoneNumber, imageId) +// -> single entry from getTrackedImages(phoneNumber), or undefined + +function recordEdits(phoneNumber, imageId, newEdits) +// Merges newEdits into the in-memory Map<"phone:id", tagMappings> and returns the merged object. +// Only called after a generate-variation call succeeds (see actions.js below) — +// a failed/timed-out edit does not get committed, so a retry starts from the last-known-good state. +``` + +Catalog read failures (missing/invalid file) are logged and treated as an empty catalog (no images tracked) rather than crashing the process. + +## `metaUpload.js` + +Left in place, untouched, exports unchanged — but no longer imported by `actions.js`. `generate-variation`'s `status` response already returns a public, directly-fetchable `thumbnailUrl`, so there's no upload hop needed. `metaUpload.test.js` stays as-is since the module's own behavior isn't changing. + +## Rewritten `actions.js` + +```js +async function actionCheckAllowedEdits(phoneNumber, imageId) { + // unknown-image guard unchanged (formatUnknownImageMessage) + // getTaggedDocument(image.docId) -> collectTaggedElements -> formatAllowedEdits(image.name, elements) + // Express API errors: log technical detail, return a generic friendly retry message (no crash, no raw error to the user) +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + // unknown-image guard unchanged + // getTaggedDocument(image.docId) -> collectTaggedElements -> allowedNames + // requestedKeys not in allowedNames -> same "I can't edit X, allowed edits are: " rejection as before, no Express/generate call made + // mergedEdits = { ...image.currentEdits, ...edits } + // pages = pagesForEdits(elements, Object.keys(mergedEdits)) + // preferredDocumentName = buildPreferredDocumentName(image.name) + // generateVariation(image.docId, mergedEdits, pages, preferredDocumentName) -> jobId + // pollJobStatus(jobId) -> result + // recordEdits(phoneNumber, imageId, edits) // only on success + // sendImage(phoneNumber, result.document.thumbnailUrl) + // generate/poll errors: log technical detail, return a generic friendly retry message + // Returns the same "Updated \"\":\n" confirmation text as before on success +} +``` + +`actionListCampaignGraphics` and `actionGenerateBulkGraphics` are unchanged (still out of scope — their existing `// TODO` mocks stay as-is). + +## `app.js` — webhook logging only + +No behavior change to the GPT tool flow. In the incoming-message loop, before the existing `if (!userText) continue`, add labeled logging for fields that might carry the UI app's per-message metadata, so a real test send lets you find where (if anywhere) a docID rides along in this app's webhook payload, independent of the `data/express-templates.json` catalog: + +```js +if (message.context) console.log('[webhook] message.context:', JSON.stringify(message.context)); +if (message.referral) console.log('[webhook] message.referral:', JSON.stringify(message.referral)); +if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); +``` + +This is observability only — no new logic acts on these fields. Actually handling incoming image messages (or any other message type beyond text) stays out of scope, same as the original design. + +## Environment variables + +New, added to `render.yaml` (`sync: false` for secrets, matching existing `OPENAI_API_KEY` pattern): + +- `EXPRESS_CLIENT_ID` (required, secret) +- `EXPRESS_CLIENT_SECRET` (required, secret) +- `EXPRESS_API_SCOPE` (optional, has default) +- `EXPRESS_IMS_TOKEN_URL` (optional, has default) +- `EXPRESS_API_BASE_URL` (optional, has default) +- `EXPRESS_TEMPLATES_FILE` (optional, has default) +- `EXPRESS_STATUS_POLL_INTERVAL_MS` (optional, has default) +- `EXPRESS_STATUS_POLL_TIMEOUT_MS` (optional, has default) + +## Error handling + +- Unknown `image_id` (GPT hallucination): unchanged friendly message, no crash. +- Disallowed edit field(s): unchanged rejection listing what's actually allowed, no Express calls made — now driven by real `taggedElements` instead of hardcoded layers. +- Any Adobe Express HTTP error (auth failure, 404 doc not found, network error), and any poll timeout/failure: caught in `actions.js`, logged with technical detail (status, docId/jobId, message), and surfaced to the user as a short generic retry message — never a raw stack trace or API error body over WhatsApp. +- A failed/timed-out edit does not get merged into the conversation's `tagMappings`, so retrying re-sends the last-known-good edit set plus the new attempt, rather than compounding a bad state. + +## Testing + +No new npm dependencies — tests stub `global.fetch` directly (Node's built-in `node:test`, matching the existing pattern; `app.js` already relies on global `fetch` for WhatsApp calls). + +- `expressAuth.test.js` (new): fetches and caches a token; refetches once near-expiry; builds the right headers. +- `expressApi.test.js` (rewritten): `getTaggedDocument`/`generateVariation`/`getJobStatus` send the right URL/method/body/headers and parse responses correctly; `pollJobStatus` resolves on `succeeded`, throws on `failed`, throws on timeout without exceeding it; `collectTaggedElements`/`formatAllowedEdits`/`pagesForEdits`/`buildPreferredDocumentName` unit-tested directly against sample API response shapes. +- `imageStore.test.js` (rewritten): reads a fixture catalog file; `recordEdits` accumulates correctly per `(phoneNumber, imageId)`; missing/invalid catalog file degrades to an empty list without throwing. +- `actions.test.js` (rewritten): known-image allowed-edits listing; unknown-image guard; disallowed-field rejection (no generate call); happy path (generate → poll → `sendImage` called with `thumbnailUrl`, edits recorded); generate/poll failure path (friendly message, `sendImage` not called, edits not recorded). +- `metaUpload.test.js`: untouched. + +## Out of scope + +- Keeping the two repos' `data/express-templates.json` copies in sync. +- Any new logic acting on `message.context`/`message.referral`/`message.image` beyond logging them. +- `actionListCampaignGraphics` / `actionGenerateBulkGraphics` real implementations. +- Handling of non-text incoming WhatsApp message types beyond the added logging. diff --git a/package.json b/package.json index 43954fe280..f68ca9be99 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,17 @@ "name": "express-hello-world", "version": "1.0.0", "description": "Express Hello World on Render", - "main": "app.js", + "main": "src/app.js", "repository": "https://github.com/render-examples/express-hello-world", "author": "Render Developers", "license": "MIT", "private": false, "scripts": { - "start": "node app.js" + "start": "node src/app.js", + "test": "node --test" }, "dependencies": { - "express": "^5.0.0" + "express": "^5.0.0", + "openai": "^6.45.0" } } diff --git a/render.yaml b/render.yaml index 9583d53df2..869c1badac 100644 --- a/render.yaml +++ b/render.yaml @@ -4,7 +4,35 @@ services: runtime: node plan: free buildCommand: yarn install --frozen-lockfile - startCommand: node app.js + startCommand: node src/app.js envVars: - key: NODE_ENV value: production + - key: VERIFY_TOKEN + sync: false + - key: WHATSAPP_PHONE_NUMBER_ID + sync: false + - key: WHATSAPP_TOKEN + sync: false + - key: OPENAI_API_KEY + sync: false + - key: OPENAI_BASE_URL + sync: false + - key: OPENAI_MODEL + sync: false + - key: EXPRESS_CLIENT_ID + sync: false + - key: EXPRESS_CLIENT_SECRET + sync: false + - key: EXPRESS_API_SCOPE + sync: false + - key: EXPRESS_IMS_TOKEN_URL + sync: false + - key: EXPRESS_API_BASE_URL + sync: false + - key: EXPRESS_TEMPLATES_FILE + sync: false + - key: EXPRESS_STATUS_POLL_INTERVAL_MS + sync: false + - key: EXPRESS_STATUS_POLL_TIMEOUT_MS + sync: false diff --git a/src/actions.js b/src/actions.js new file mode 100644 index 0000000000..9c6e29fa46 --- /dev/null +++ b/src/actions.js @@ -0,0 +1,100 @@ +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); +const expressApi = require('./express/expressApi'); + +function formatUnknownImageMessage(phoneNumber) { + const images = getTrackedImages(phoneNumber); + const list = images.map((image) => `- ${image.name}`).join('\n'); + return `I couldn't find that image. Here's what I have:\n${list}`; +} + +async function actionListCampaignGraphics() { + // TODO: fetch from campaign API + return 'Graphics in your current campaign:\n1. Croma Earbuds'; +} + +function withCurrentEdits(elements, currentEdits) { + return elements.map((element) => + element.name in currentEdits ? { ...element, value: currentEdits[element.name] } : element + ); +} + +async function actionCheckAllowedEdits(phoneNumber, imageId) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + try { + const doc = await expressApi.getTaggedDocument(image.docId); + const elements = expressApi.collectTaggedElements(doc); + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits); + } catch (err) { + console.error('[actionCheckAllowedEdits] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't check the allowed edits for "${image.name}" right now. Please try again in a moment.`; + } +} + +async function actionEditGraphic(phoneNumber, imageId, edits, { sendImage }) { + const image = findTrackedImage(phoneNumber, imageId); + if (!image) { + return formatUnknownImageMessage(phoneNumber); + } + + let elements; + try { + const doc = await expressApi.getTaggedDocument(image.docId); + elements = expressApi.collectTaggedElements(doc); + } catch (err) { + console.error('[actionEditGraphic] Express API error', { docId: image.docId, message: err.message }); + return `Sorry, I couldn't reach Adobe Express to apply that edit. Please try again in a moment.`; + } + + const allowedNames = elements.map((element) => element.name); + const requestedKeys = Object.keys(edits || {}); + const disallowedKeys = requestedKeys.filter((key) => !allowedNames.includes(key)); + + if (disallowedKeys.length > 0) { + const elementsWithCurrentEdits = withCurrentEdits(elements, image.currentEdits); + return `I can't edit ${disallowedKeys.join(', ')} on "${image.name}". ${expressApi.formatAllowedEdits(image.name, elementsWithCurrentEdits)}`; + } + + const mergedEdits = { ...image.currentEdits, ...edits }; + const pages = expressApi.pagesForEdits(elements, Object.keys(mergedEdits)); + const preferredDocumentName = expressApi.buildPreferredDocumentName(image.name); + + let thumbnailUrl; + try { + const { statusUrl } = await expressApi.generateVariation(image.docId, mergedEdits, pages, preferredDocumentName); + const result = await expressApi.pollJobStatus(statusUrl); + thumbnailUrl = result.document.thumbnailUrl; + } catch (err) { + console.error('[actionEditGraphic] generate/poll error', { docId: image.docId, message: err.message }); + return `Sorry, something went wrong generating your updated "${image.name}". Please try again.`; + } + + recordEdits(phoneNumber, imageId, edits); + + const summary = Object.entries(edits).map(([key, value]) => `• ${key}: ${value}`).join('\n'); + + try { + await sendImage(phoneNumber, thumbnailUrl); + } catch (err) { + console.error('[actionEditGraphic] sendImage error', { docId: image.docId, message: err.message }); + return `Updated "${image.name}", but I couldn't send the image right now — try asking me to resend it.`; + } + + return `Updated "${image.name}":\n${summary}`; +} + +async function actionGenerateBulkGraphics(filename) { + // TODO: parse CSV/Excel and call Adobe Express API per row + return `Bulk generation complete! Graphics created from ${filename || 'your uploaded file'}.`; +} + +module.exports = { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +}; diff --git a/src/actions.test.js b/src/actions.test.js new file mode 100644 index 0000000000..a662e30598 --- /dev/null +++ b/src/actions.test.js @@ -0,0 +1,149 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { actionCheckAllowedEdits, actionEditGraphic } = require('./actions'); +const expressApi = require('./express/expressApi'); +const { findTrackedImage, recordEdits } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +const SAMPLE_ELEMENTS_DOC = { + documentPages: [ + { + pageNumber: 1, + taggedElements: [ + { name: 'heading', type: 'text', value: 'The X-Phone Pro is here!' }, + { name: 'cta', type: 'text', value: 'Available at our store starting 15 Aug 20XX.' }, + ], + }, + ], +}; + +test('actionCheckAllowedEdits lists the tagged elements for a known image', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async (docId) => { + assert.equal(docId, 'urn:doc:1'); + return SAMPLE_ELEMENTS_DOC; + }; + + const reply = await actionCheckAllowedEdits('phone-1', 'img_1'); + + assert.match(reply, /Croma Earbuds/); + assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); + assert.match(reply, /cta: currently/); +}); + +test('actionCheckAllowedEdits shows the latest edited value instead of the stale original document value', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + recordEdits('phone-1b', 'img_1', { cta: '20% off' }); + + const reply = await actionCheckAllowedEdits('phone-1b', 'img_1'); + + assert.match(reply, /cta: currently "20% off"/); + assert.doesNotMatch(reply, /Available at our store starting 15 Aug 20XX\./); + assert.match(reply, /heading: currently "The X-Phone Pro is here!"/); +}); + +test('actionCheckAllowedEdits reports unknown images without throwing', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + + const reply = await actionCheckAllowedEdits('phone-2', 'img_nope'); + + assert.match(reply, /couldn't find that image/); +}); + +test('actionCheckAllowedEdits returns a friendly message when the Express API call fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => { + throw new Error('getTaggedDocument failed 500: boom'); + }; + + const reply = await actionCheckAllowedEdits('phone-3', 'img_1'); + + assert.match(reply, /couldn't check the allowed edits/); +}); + +test('actionEditGraphic rejects edits outside the tagged elements and makes no generate call', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { + throw new Error('should not be called'); + }; + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-4', 'img_1', { background_color: 'red' }, { sendImage }); + + assert.match(reply, /can't edit background_color/); + assert.equal(sendImageCalled, false); +}); + +test('actionEditGraphic applies an allowed edit end-to-end: generates, polls, sends the thumbnail, and records the edit', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async (docId, tagMappings, pages, preferredDocumentName) => { + assert.equal(docId, 'urn:doc:1'); + assert.deepEqual(tagMappings, { cta: '20% off' }); + assert.equal(pages, '1'); + assert.match(preferredDocumentName, /^Croma Earbuds-edit-\d+$/); + return { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }; + }; + expressApi.pollJobStatus = async (statusUrl) => { + assert.equal(statusUrl, 'https://express-api.adobe.io/status/job-1'); + return { status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }; + }; + + const sentCalls = []; + const sendImage = async (to, link) => { sentCalls.push({ to, link }); }; + + const reply = await actionEditGraphic('phone-5', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /Updated "Croma Earbuds"/); + assert.equal(sentCalls.length, 1); + assert.equal(sentCalls[0].to, 'phone-5'); + assert.equal(sentCalls[0].link, 'https://example.com/thumb.png'); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +}); + +test('actionEditGraphic returns a friendly message and does not record the edit when generation fails', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => { throw new Error('generateVariation failed 500: boom'); }; + + let sendImageCalled = false; + const sendImage = async () => { sendImageCalled = true; }; + + const reply = await actionEditGraphic('phone-6', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /something went wrong generating/); + assert.equal(sendImageCalled, false); + + const image = findTrackedImage('phone-6', 'img_1'); + assert.deepEqual(image.currentEdits, {}); +}); + +test('actionEditGraphic tells the user delivery failed but keeps the recorded edit when sendImage throws', async () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Croma Earbuds', docId: 'urn:doc:1' }]); + expressApi.getTaggedDocument = async () => SAMPLE_ELEMENTS_DOC; + expressApi.generateVariation = async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); + expressApi.pollJobStatus = async () => ({ status: 'succeeded', document: { thumbnailUrl: 'https://example.com/thumb.png' } }); + + const sendImage = async () => { throw new Error('WhatsApp could not fetch the link'); }; + + const reply = await actionEditGraphic('phone-7', 'img_1', { cta: '20% off' }, { sendImage }); + + assert.match(reply, /couldn't send the image right now/); + assert.doesNotMatch(reply, /something went wrong generating/); + + const image = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(image.currentEdits, { cta: '20% off' }); +}); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000000..1baa2ef9d1 --- /dev/null +++ b/src/app.js @@ -0,0 +1,271 @@ +const express = require('express'); +const OpenAI = require('openai'); +const { getTrackedImages } = require('./imageStore'); +const { + actionListCampaignGraphics, + actionCheckAllowedEdits, + actionEditGraphic, + actionGenerateBulkGraphics, +} = require('./actions'); + +const app = express(); +app.use(express.json()); + +const port = process.env.PORT || 3000; +const verifyToken = process.env.VERIFY_TOKEN; +const whatsappPhoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID; +const whatsappToken = process.env.WHATSAPP_TOKEN; +const openaiBaseURL = process.env.OPENAI_BASE_URL || undefined; +const openaiModel = process.env.OPENAI_MODEL || 'gpt-4o'; + +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: openaiBaseURL }); + +// In-memory conversation history per phone number (last 20 messages kept) +const conversationHistory = new Map(); + +function getHistory(phoneNumber) { + return conversationHistory.get(phoneNumber) || []; +} + +function appendHistory(phoneNumber, role, content) { + const history = conversationHistory.get(phoneNumber) || []; + history.push({ role, content }); + if (history.length > 20) history.shift(); + conversationHistory.set(phoneNumber, history); +} + +// ── WhatsApp helpers ───────────────────────────────────────────────────────── + +async function whatsappPost(body) { + const url = `https://graph.facebook.com/v19.0/${whatsappPhoneNumberId}/messages`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${whatsappToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`WhatsApp API error ${response.status}: ${text}`); + } + return response.json(); +} + +function sendText(to, text) { + return whatsappPost({ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } }); +} + +function sendImage(to, link) { + return whatsappPost({ messaging_product: 'whatsapp', to, type: 'image', image: { link } }); +} + +// ── GPT tool definitions ───────────────────────────────────────────────────── + +const tools = [ + { + type: 'function', + function: { + name: 'list_campaign_graphics', + description: 'List all graphics available in the current campaign', + parameters: { type: 'object', properties: {} }, + }, + }, + { + type: 'function', + function: { + name: 'ask_for_more_information', + description: 'Ask the user a clarifying question when the request is ambiguous or incomplete', + parameters: { + type: 'object', + properties: { + question: { type: 'string', description: 'The clarifying question to send to the user' }, + }, + required: ['question'], + }, + }, + }, + { + type: 'function', + function: { + name: 'check_allowed_edits', + description: + 'Check what edits are permitted on a specific graphic. Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image the user is asking about, from the tracked images list' }, + }, + required: ['image_id'], + }, + }, + }, + { + type: 'function', + function: { + name: 'edit_graphic', + description: + 'Edit a specific graphic via Adobe Express API (e.g. change discount text, colors). Pick image_id from the "Images previously sent to this user" list in the system prompt that best matches what the user is referring to.', + parameters: { + type: 'object', + properties: { + image_id: { type: 'string', description: 'The id of the image to edit, from the tracked images list' }, + edits: { + type: 'object', + description: 'Key-value pairs of edits to apply, e.g. { "discount_text": "70%" }', + }, + }, + required: ['image_id', 'edits'], + }, + }, + }, + { + type: 'function', + function: { + name: 'generate_bulk_graphics', + description: 'Generate multiple graphics from an uploaded CSV or Excel file', + parameters: { + type: 'object', + properties: { + filename: { type: 'string', description: 'Name of the uploaded CSV or Excel file' }, + }, + }, + }, + }, +]; + +// ── GPT decision engine ────────────────────────────────────────────────────── + +async function decideAction(phoneNumber, userMessage) { + const last3 = getHistory(phoneNumber).slice(-3); + const trackedImages = getTrackedImages(phoneNumber); + const imagesList = trackedImages.map((image) => `- ${image.id}: ${image.name}`).join('\n'); + + const messages = [ + { + role: 'system', + content: `You are a WhatsApp assistant for managing marketing campaign graphics via Adobe Express. +Analyze the user's message and conversation history, then call the appropriate tool. +Always call exactly one tool — never reply with plain text. +If the request is ambiguous or missing details, use ask_for_more_information. + +Images previously sent to this user (reference by id): +${imagesList}`, + }, + ...last3, + { role: 'user', content: userMessage }, + ]; + + console.log( + `[decideAction] calling chat.completions.create — model: ${openaiModel}, baseURL: ${openai.baseURL}, phone: ${phoneNumber}` + ); + + const response = await openai.chat.completions.create({ + model: openaiModel, + messages, + tools, + tool_choice: 'required', + }); + + return response.choices[0].message; +} + +// ── Webhook routes ─────────────────────────────────────────────────────────── + +app.get('/', (req, res) => { + const { 'hub.mode': mode, 'hub.challenge': challenge, 'hub.verify_token': token } = req.query; + if (mode === 'subscribe' && token === verifyToken) { + console.log('WEBHOOK VERIFIED'); + res.status(200).send(challenge); + } else { + res.status(403).end(); + } +}); + +app.post('/', async (req, res) => { + const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); + console.log(`\n\nWebhook received ${timestamp}\n`); + console.log(JSON.stringify(req.body, null, 2)); + + res.status(200).end(); + + try { + const messages = req.body?.entry?.[0]?.changes?.[0]?.value?.messages; + if (!messages?.length) return; + + for (const message of messages) { + if (message.context) console.log('[webhook] message.context:', JSON.stringify(message.context)); + if (message.referral) console.log('[webhook] message.referral:', JSON.stringify(message.referral)); + if (message.image) console.log('[webhook] message.image:', JSON.stringify(message.image)); + + const userText = message?.text?.body; + if (!userText) continue; + + const phoneNumber = message.from; + console.log(`Message from ${phoneNumber}: ${userText}`); + + appendHistory(phoneNumber, 'user', userText); + + const gptMessage = await decideAction(phoneNumber, userText); + const toolCall = gptMessage.tool_calls?.[0]; + if (!toolCall) continue; + + const action = toolCall.function.name; + const args = JSON.parse(toolCall.function.arguments || '{}'); + console.log(`GPT chose action: ${action}`, args); + + let replyText; + + switch (action) { + case 'list_campaign_graphics': + await sendText(phoneNumber, '⏳ Fetching campaign graphics...'); + replyText = await actionListCampaignGraphics(); + break; + + case 'ask_for_more_information': + replyText = args.question; + break; + + case 'check_allowed_edits': + await sendText(phoneNumber, '⏳ Checking allowed edits...'); + replyText = await actionCheckAllowedEdits(phoneNumber, args.image_id); + break; + + case 'edit_graphic': + await sendText(phoneNumber, '⏳ Applying edits to your graphic...'); + replyText = await actionEditGraphic(phoneNumber, args.image_id, args.edits, { sendImage }); + break; + + case 'generate_bulk_graphics': + await sendText(phoneNumber, '⏳ Generating graphics from your file, this may take a moment...'); + replyText = await actionGenerateBulkGraphics(args.filename); + break; + + default: + replyText = "Sorry, I couldn't figure out how to handle that request."; + } + + await sendText(phoneNumber, replyText); + appendHistory(phoneNumber, 'assistant', replyText); + } + } catch (err) { + console.error('Error handling message:', { + message: err.message, + status: err.status, + code: err.code, + type: err.type, + requestID: err.requestID, + error: err.error, + model: openaiModel, + baseURL: openai.baseURL, + }); + } +}); + +app.listen(port, () => { + console.log(`\nListening on port ${port}\n`); + console.log(`[startup] OPENAI_BASE_URL: ${process.env.OPENAI_BASE_URL ?? '(unset, defaults to api.openai.com)'}`); + console.log(`[startup] OPENAI_MODEL: ${openaiModel}`); + console.log(`[startup] OPENAI_API_KEY set: ${Boolean(process.env.OPENAI_API_KEY)}`); +}); diff --git a/src/express/expressApi.js b/src/express/expressApi.js new file mode 100644 index 0000000000..2be744773e --- /dev/null +++ b/src/express/expressApi.js @@ -0,0 +1,102 @@ +const { buildAuthHeaders } = require('./expressAuth'); + +function apiBaseUrl() { + return process.env.EXPRESS_API_BASE_URL || 'https://express-api.adobe.io'; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getTaggedDocument(docId) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/tagged-documents/${encodeURIComponent(docId)}`, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getTaggedDocument failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function generateVariation(docId, tagMappings, pages, preferredDocumentName) { + const headers = await buildAuthHeaders(); + const response = await fetch(`${apiBaseUrl()}/beta/generate-variation`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: docId, + variationDetails: { pages, preferredDocumentName, tagMappings }, + }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`generateVariation failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function getJobStatus(statusUrl) { + const headers = await buildAuthHeaders(); + const response = await fetch(statusUrl, { headers }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`getJobStatus failed ${response.status}: ${text}`); + } + return response.json(); +} + +async function pollJobStatus(statusUrl, { intervalMs, timeoutMs } = {}) { + const interval = intervalMs ?? Number(process.env.EXPRESS_STATUS_POLL_INTERVAL_MS || 2000); + const timeout = timeoutMs ?? Number(process.env.EXPRESS_STATUS_POLL_TIMEOUT_MS || 60000); + const deadline = Date.now() + timeout; + + for (;;) { + const result = await getJobStatus(statusUrl); + if (result.status === 'succeeded') return result; + if (result.status === 'failed') throw new Error(`Express job at ${statusUrl} failed`); + if (Date.now() >= deadline) throw new Error(`Express job at ${statusUrl} timed out after ${timeout}ms`); + await sleep(interval); + } +} + +function collectTaggedElements(taggedDocument) { + const elements = []; + for (const page of taggedDocument.documentPages || []) { + for (const element of page.taggedElements || []) { + elements.push({ ...element, pageNumber: page.pageNumber }); + } + } + return elements; +} + +function formatAllowedEdits(name, elements) { + const lines = elements.map((element) => + element.type === 'text' + ? `- ${element.name}: currently "${element.value}"` + : `- ${element.name} (${element.type})` + ); + const example = elements[0]?.name || 'a field'; + return `Edits allowed on "${name}":\n${lines.join('\n')}\nTell me what you'd like to change and to what, e.g. "change ${example} to ...".`; +} + +function pagesForEdits(elements, editKeys) { + const pageNumbers = new Set( + elements.filter((element) => editKeys.includes(element.name)).map((element) => element.pageNumber) + ); + return [...pageNumbers].sort((a, b) => a - b).join(','); +} + +function buildPreferredDocumentName(baseName) { + return `${baseName}-edit-${Date.now()}`; +} + +module.exports = { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +}; diff --git a/src/express/expressApi.test.js b/src/express/expressApi.test.js new file mode 100644 index 0000000000..c0b974d0c7 --- /dev/null +++ b/src/express/expressApi.test.js @@ -0,0 +1,204 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + getTaggedDocument, + generateVariation, + getJobStatus, + pollJobStatus, + collectTaggedElements, + formatAllowedEdits, + pagesForEdits, + buildPreferredDocumentName, +} = require('./expressApi'); + +const originalFetch = global.fetch; + +function stubFetch(handlers) { + global.fetch = async (url, options) => { + if (url.includes('ims-na1.adobelogin.com')) { + return { ok: true, json: async () => ({ access_token: 'tok-test', expires_in: 86400000 }) }; + } + for (const [pattern, handler] of handlers) { + if (pattern.test(url)) return handler(url, options); + } + throw new Error(`Unexpected fetch call: ${url}`); + }; +} + +test('getTaggedDocument fetches and returns the tagged document', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async (url, options) => { + assert.match(url, /\/beta\/tagged-documents\/urn%3Aaaid%3Asc%3AAP%3Aabc$/); + assert.equal(options.headers.Authorization, 'Bearer tok-test'); + assert.equal(options.headers['X-API-KEY'], 'client-1'); + return { ok: true, json: async () => ({ name: 'Croma2-Doc', id: 'urn:aaid:sc:AP:abc', documentPages: [] }) }; + }], + ]); + + const doc = await getTaggedDocument('urn:aaid:sc:AP:abc'); + + assert.equal(doc.name, 'Croma2-Doc'); + global.fetch = originalFetch; +}); + +test('getTaggedDocument throws with the status and body on a non-ok response', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/tagged-documents\//, async () => ({ ok: false, status: 404, text: async () => 'not found' })], + ]); + + await assert.rejects(() => getTaggedDocument('urn:missing'), /404/); + global.fetch = originalFetch; +}); + +test('generateVariation posts the right body and returns jobId/statusUrl', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + stubFetch([ + [/\/beta\/generate-variation$/, async (url, options) => { + assert.equal(options.method, 'POST'); + const body = JSON.parse(options.body); + assert.deepEqual(body, { + id: 'urn:doc:1', + variationDetails: { + pages: '1', + preferredDocumentName: 'Croma Earbuds-edit-123', + tagMappings: { cta: '20% off' }, + }, + }); + return { ok: true, json: async () => ({ jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }) }; + }], + ]); + + const result = await generateVariation('urn:doc:1', { cta: '20% off' }, '1', 'Croma Earbuds-edit-123'); + + assert.deepEqual(result, { jobId: 'job-1', statusUrl: 'https://express-api.adobe.io/status/job-1' }); + global.fetch = originalFetch; +}); + +test('getJobStatus fetches the exact statusUrl provided, with no reconstruction', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-1'; + stubFetch([ + [/\/status\/job-1$/, async (url) => { + assert.equal(url, statusUrl); + return { + ok: true, + json: async () => ({ jobId: 'job-1', status: 'succeeded', document: { name: 'GD2.express', id: 'urn:doc:2', thumbnailUrl: 'https://example.com/thumb.png' } }), + }; + }], + ]); + + const result = await getJobStatus(statusUrl); + + assert.equal(result.status, 'succeeded'); + assert.equal(result.document.thumbnailUrl, 'https://example.com/thumb.png'); + global.fetch = originalFetch; +}); + +test('pollJobStatus resolves once status is succeeded', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-2'; + let calls = 0; + stubFetch([ + [/\/status\/job-2$/, async (url) => { + assert.equal(url, statusUrl); + calls += 1; + const status = calls < 2 ? 'running' : 'succeeded'; + return { + ok: true, + json: async () => ({ + jobId: 'job-2', + status, + document: status === 'succeeded' ? { thumbnailUrl: 'https://example.com/thumb2.png' } : undefined, + }), + }; + }], + ]); + + const result = await pollJobStatus(statusUrl, { intervalMs: 1, timeoutMs: 1000 }); + + assert.equal(result.status, 'succeeded'); + assert.equal(calls, 2); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws when status is failed', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-3'; + stubFetch([ + [/\/status\/job-3$/, async () => ({ ok: true, json: async () => ({ jobId: 'job-3', status: 'failed' }) })], + ]); + + await assert.rejects( + () => pollJobStatus(statusUrl, { intervalMs: 1, timeoutMs: 1000 }), + (err) => err.message === `Express job at ${statusUrl} failed` + ); + global.fetch = originalFetch; +}); + +test('pollJobStatus throws once the timeout elapses without succeeding', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-1'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-1'; + const statusUrl = 'https://express-api.adobe.io/status/job-4'; + stubFetch([ + [/\/status\/job-4$/, async () => ({ ok: true, json: async () => ({ jobId: 'job-4', status: 'running' }) })], + ]); + + await assert.rejects( + () => pollJobStatus(statusUrl, { intervalMs: 5, timeoutMs: 20 }), + (err) => /timed out/.test(err.message) && err.message.includes(statusUrl) + ); + global.fetch = originalFetch; +}); + +test('collectTaggedElements flattens taggedElements across all pages with pageNumber attached', () => { + const doc = { + documentPages: [ + { pageNumber: 1, taggedElements: [{ name: 'heading', type: 'text', value: 'Hi' }] }, + { pageNumber: 2, taggedElements: [{ name: 'footer', type: 'text', value: 'Bye' }] }, + ], + }; + + const elements = collectTaggedElements(doc); + + assert.deepEqual(elements, [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'footer', type: 'text', value: 'Bye', pageNumber: 2 }, + ]); +}); + +test('formatAllowedEdits lists text elements with their current value and non-text elements with just their type', () => { + const elements = [ + { name: 'heading', type: 'text', value: 'Hi', pageNumber: 1 }, + { name: 'logo', type: 'image', pageNumber: 1 }, + ]; + + const message = formatAllowedEdits('Croma Earbuds', elements); + + assert.match(message, /Edits allowed on "Croma Earbuds":/); + assert.match(message, /- heading: currently "Hi"/); + assert.match(message, /- logo \(image\)/); +}); + +test('pagesForEdits returns the sorted, comma-joined page numbers containing the edited fields', () => { + const elements = [ + { name: 'heading', pageNumber: 2 }, + { name: 'cta', pageNumber: 1 }, + { name: 'footer', pageNumber: 1 }, + ]; + + assert.equal(pagesForEdits(elements, ['cta']), '1'); + assert.equal(pagesForEdits(elements, ['cta', 'heading']), '1,2'); +}); + +test('buildPreferredDocumentName appends a timestamp suffix to the base name', () => { + const name = buildPreferredDocumentName('Croma Earbuds'); + assert.match(name, /^Croma Earbuds-edit-\d+$/); +}); diff --git a/src/express/expressAuth.js b/src/express/expressAuth.js new file mode 100644 index 0000000000..a5dfb262c6 --- /dev/null +++ b/src/express/expressAuth.js @@ -0,0 +1,44 @@ +const IMS_TOKEN_URL = process.env.EXPRESS_IMS_TOKEN_URL || 'https://ims-na1.adobelogin.com/ims/token/v3'; +const DEFAULT_SCOPE = 'ee.express_api,openid,AdobeID,read_organizations,additional_info.projectedProductContext'; +const REFRESH_MARGIN_MS = 60_000; + +let cachedToken = null; // { accessToken, expiresAt } + +async function getAccessToken() { + if (cachedToken && cachedToken.expiresAt - Date.now() > REFRESH_MARGIN_MS) { + return cachedToken.accessToken; + } + + const response = await fetch(IMS_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: process.env.EXPRESS_CLIENT_ID, + client_secret: process.env.EXPRESS_CLIENT_SECRET, + scope: process.env.EXPRESS_API_SCOPE || DEFAULT_SCOPE, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`IMS token request failed ${response.status}: ${text}`); + } + + const data = await response.json(); + cachedToken = { + accessToken: data.access_token, + expiresAt: Date.now() + Number(data.expires_in), + }; + return cachedToken.accessToken; +} + +async function buildAuthHeaders() { + const token = await getAccessToken(); + return { + Authorization: `Bearer ${token}`, + 'X-API-KEY': process.env.EXPRESS_CLIENT_ID, + }; +} + +module.exports = { getAccessToken, buildAuthHeaders }; diff --git a/src/express/expressAuth.test.js b/src/express/expressAuth.test.js new file mode 100644 index 0000000000..b9b7012386 --- /dev/null +++ b/src/express/expressAuth.test.js @@ -0,0 +1,90 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const originalFetch = global.fetch; + +function freshExpressAuth() { + delete require.cache[require.resolve('./expressAuth')]; + return require('./expressAuth'); +} + +test('getAccessToken fetches a token from the IMS endpoint using client credentials', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async (url, options) => { + assert.equal(url, 'https://ims-na1.adobelogin.com/ims/token/v3'); + assert.equal(options.method, 'POST'); + assert.equal(options.headers['Content-Type'], 'application/x-www-form-urlencoded'); + const body = options.body.toString(); + assert.match(body, /grant_type=client_credentials/); + assert.match(body, /client_id=client-123/); + assert.match(body, /client_secret=secret-456/); + return { ok: true, json: async () => ({ access_token: 'tok-abc', expires_in: 86400000, token_type: 'bearer' }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const token = await getAccessToken(); + + assert.equal(token, 'tok-abc'); + global.fetch = originalFetch; +}); + +test('getAccessToken caches the token and does not refetch on a second call', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: 'tok-cached', expires_in: 86400000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-cached'); + assert.equal(second, 'tok-cached'); + assert.equal(fetchCalls, 1); + global.fetch = originalFetch; +}); + +test('getAccessToken refetches once the cached token is within 60s of expiring', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + let fetchCalls = 0; + global.fetch = async () => { + fetchCalls += 1; + return { ok: true, json: async () => ({ access_token: `tok-${fetchCalls}`, expires_in: 30000 }) }; + }; + + const { getAccessToken } = freshExpressAuth(); + const first = await getAccessToken(); + const second = await getAccessToken(); + + assert.equal(first, 'tok-1'); + assert.equal(second, 'tok-2'); + assert.equal(fetchCalls, 2); + global.fetch = originalFetch; +}); + +test('getAccessToken throws with the status and body when the IMS endpoint errors', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-123'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-456'; + global.fetch = async () => ({ ok: false, status: 401, text: async () => 'invalid client' }); + + const { getAccessToken } = freshExpressAuth(); + await assert.rejects(() => getAccessToken(), /401/); + global.fetch = originalFetch; +}); + +test('buildAuthHeaders returns Authorization and X-API-KEY headers', async () => { + process.env.EXPRESS_CLIENT_ID = 'client-789'; + process.env.EXPRESS_CLIENT_SECRET = 'secret-000'; + global.fetch = async () => ({ ok: true, json: async () => ({ access_token: 'tok-xyz', expires_in: 86400000 }) }); + + const { buildAuthHeaders } = freshExpressAuth(); + const headers = await buildAuthHeaders(); + + assert.deepEqual(headers, { Authorization: 'Bearer tok-xyz', 'X-API-KEY': 'client-789' }); + global.fetch = originalFetch; +}); diff --git a/src/imageStore.js b/src/imageStore.js new file mode 100644 index 0000000000..a2d75c0d02 --- /dev/null +++ b/src/imageStore.js @@ -0,0 +1,43 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function catalogPath() { + return process.env.EXPRESS_TEMPLATES_FILE || path.join(__dirname, '..', 'data', 'express-templates.json'); +} + +function loadCatalog() { + try { + const raw = fs.readFileSync(catalogPath(), 'utf8'); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + console.error('[imageStore] failed to read catalog', { path: catalogPath(), message: 'catalog is not an array' }); + return []; + } + return parsed; + } catch (err) { + console.error('[imageStore] failed to read catalog', { path: catalogPath(), message: err.message }); + return []; + } +} + +const conversationEdits = new Map(); + +function getTrackedImages(phoneNumber) { + return loadCatalog().map((entry) => ({ + ...entry, + currentEdits: conversationEdits.get(`${phoneNumber}:${entry.id}`) || {}, + })); +} + +function findTrackedImage(phoneNumber, imageId) { + return getTrackedImages(phoneNumber).find((image) => image.id === imageId); +} + +function recordEdits(phoneNumber, imageId, newEdits) { + const key = `${phoneNumber}:${imageId}`; + const merged = { ...(conversationEdits.get(key) || {}), ...newEdits }; + conversationEdits.set(key, merged); + return merged; +} + +module.exports = { getTrackedImages, findTrackedImage, recordEdits }; diff --git a/src/imageStore.test.js b/src/imageStore.test.js new file mode 100644 index 0000000000..f175afb835 --- /dev/null +++ b/src/imageStore.test.js @@ -0,0 +1,75 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { getTrackedImages, findTrackedImage, recordEdits } = require('./imageStore'); + +function writeFixtureCatalog(entries) { + const fixturePath = path.join(os.tmpdir(), `express-templates-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + fs.writeFileSync(fixturePath, JSON.stringify(entries)); + process.env.EXPRESS_TEMPLATES_FILE = fixturePath; +} + +test('getTrackedImages reads the catalog from EXPRESS_TEMPLATES_FILE', () => { + writeFixtureCatalog([ + { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }, + { id: 'img_2', name: 'Summer Sale Flyer', docId: 'urn:doc:2' }, + ]); + + const images = getTrackedImages('phone-1'); + + assert.equal(images.length, 2); + assert.deepEqual(images[0], { id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1', currentEdits: {} }); +}); + +test('getTrackedImages returns an empty list when the catalog file is missing', () => { + process.env.EXPRESS_TEMPLATES_FILE = path.join(os.tmpdir(), 'does-not-exist.json'); + + const images = getTrackedImages('phone-2'); + + assert.deepEqual(images, []); +}); + +test('getTrackedImages returns an empty list when the catalog is not an array', () => { + writeFixtureCatalog({}); + + const images = getTrackedImages('phone-2-non-array'); + + assert.deepEqual(images, []); +}); + +test('findTrackedImage returns the matching image by id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-3', 'img_3'); + + assert.equal(image.name, 'Croma Earbuds'); +}); + +test('findTrackedImage returns undefined for an unknown id', () => { + writeFixtureCatalog([{ id: 'img_3', name: 'Croma Earbuds', docId: 'urn:doc:3' }]); + + const image = findTrackedImage('phone-4', 'img_nope'); + + assert.equal(image, undefined); +}); + +test('recordEdits merges edits per phone number and image id, visible via findTrackedImage', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-5', 'img_1', { headline: 'Flash Sale' }); + recordEdits('phone-5', 'img_1', { discount_text: '70%' }); + + const image = findTrackedImage('phone-5', 'img_1'); + assert.deepEqual(image.currentEdits, { headline: 'Flash Sale', discount_text: '70%' }); +}); + +test('recordEdits keeps edits independent per phone number', () => { + writeFixtureCatalog([{ id: 'img_1', name: 'Diwali Offer Banner', docId: 'urn:doc:1' }]); + + recordEdits('phone-6', 'img_1', { headline: 'Only for phone-6' }); + + const otherPhoneImage = findTrackedImage('phone-7', 'img_1'); + assert.deepEqual(otherPhoneImage.currentEdits, {}); +}); diff --git a/src/metaUpload.js b/src/metaUpload.js new file mode 100644 index 0000000000..4a6a68402b --- /dev/null +++ b/src/metaUpload.js @@ -0,0 +1,10 @@ +const { randomUUID } = require('node:crypto'); + +async function uploadImageToMeta(renderedImageUrl) { + if (!renderedImageUrl.startsWith('https://mock-express.local/')) { + return renderedImageUrl; + } + return `https://mock-meta-cdn.local/media/${randomUUID()}.png`; +} + +module.exports = { uploadImageToMeta }; diff --git a/src/metaUpload.test.js b/src/metaUpload.test.js new file mode 100644 index 0000000000..e4aed1b9ca --- /dev/null +++ b/src/metaUpload.test.js @@ -0,0 +1,19 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { uploadImageToMeta } = require('./metaUpload'); + +test('uploadImageToMeta returns a mock CDN url', async () => { + const url = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + assert.match(url, /^https:\/\/mock-meta-cdn\.local\/media\/[0-9a-f-]+\.png$/); +}); + +test('uploadImageToMeta returns a different url on each call', async () => { + const first = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=1'); + const second = await uploadImageToMeta('https://mock-express.local/render/tpl_diwali?rev=2'); + assert.notEqual(first, second); +}); + +test('uploadImageToMeta passes through urls that are already publicly hosted', async () => { + const url = await uploadImageToMeta('https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); + assert.equal(url, 'https://s7ap1.scene7.com/is/image/varun/croma1-earbuds-updated'); +}); diff --git a/yarn.lock b/yarn.lock index 35c2bede4d..e4212e3db7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4,570 +4,486 @@ accepts@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + resolved "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== dependencies: mime-types "^3.0.0" negotiator "^1.0.0" -array-flatten@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-3.0.0.tgz#6428ca2ee52c7b823192ec600fa3ed2f157cd541" - integrity sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA== - -body-parser@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.0.1.tgz#979de4a43468c5624403457fd6d45f797faffbaf" - integrity sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA== +body-parser@^2.2.1: + version "2.3.0" + resolved "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz#6d8662f4d8c336028b8ac9aa24251b0ca64ba437" + integrity sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw== dependencies: - bytes "3.1.2" - content-type "~1.0.5" - debug "3.1.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.5.2" - on-finished "2.4.1" - qs "6.13.0" - raw-body "^3.0.0" - type-is "~1.6.18" - unpipe "1.0.0" - -bytes@3.1.2: + bytes "^3.1.2" + content-type "^2.0.0" + debug "^4.4.3" + http-errors "^2.0.1" + iconv-lite "^0.7.2" + on-finished "^2.4.1" + qs "^6.15.2" + raw-body "^3.0.2" + type-is "^2.1.0" + +bytes@^3.1.2, bytes@~3.1.2: version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -call-bind@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" - integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== dependencies: - es-define-property "^1.0.0" es-errors "^1.3.0" function-bind "^1.1.2" - get-intrinsic "^1.2.4" - set-function-length "^1.2.1" -content-disposition@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2" - integrity sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg== +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== dependencies: - safe-buffer "5.2.1" + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" -content-type@^1.0.5, content-type@~1.0.5: +content-disposition@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17" + integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== + +content-type@^1.0.5: version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -content-type@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== +content-type@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz#2fb3ede69dffa0af78ca7c4ce7589680638b56df" + integrity sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ== cookie-signature@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.1.tgz#790dea2cce64638c7ae04d9fabed193bd7ccf3b4" - integrity sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw== - -cookie@0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" - integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== - -debug@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" + version "1.2.2" + resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== -debug@4.3.6: - version "4.3.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" - integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== - dependencies: - ms "2.1.2" +cookie@^0.7.1: + version "0.7.2" + resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== -debug@^4.3.5: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== +debug@^4.4.0, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" -define-data-property@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" - integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - gopd "^1.0.1" - -depd@2.0.0: +depd@^2.0.0, depd@~2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -destroy@1.2.0, destroy@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" ee-first@1.1.1: version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= + resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== -encodeurl@^2.0.0, encodeurl@~2.0.0: +encodeurl@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= - -es-define-property@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" - integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== - dependencies: - get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== es-errors@^1.3.0: version "1.3.0" - resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -escape-html@^1.0.3, escape-html@~1.0.3: +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.2" + resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + +escape-html@^1.0.3: version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= + resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== -etag@^1.8.1, etag@~1.8.1: +etag@^1.8.1: version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= + resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== express@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/express/-/express-5.0.0.tgz#744f9ec86025a01aeca99e4300aa4fc050d493c7" - integrity sha512-V4UkHQc+B7ldh1YC84HCXHwf60M4BOMvp9rkvTUWCK5apqDC1Esnbid4wm6nFyVuDy8XMfETsJw5lsIGBWyo0A== + version "5.2.1" + resolved "https://registry.npmjs.org/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" + integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== dependencies: accepts "^2.0.0" - body-parser "^2.0.1" + body-parser "^2.2.1" content-disposition "^1.0.0" - content-type "~1.0.4" - cookie "0.6.0" + content-type "^1.0.5" + cookie "^0.7.1" cookie-signature "^1.2.1" - debug "4.3.6" - depd "2.0.0" - encodeurl "~2.0.0" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "^2.0.0" - fresh "2.0.0" - http-errors "2.0.0" + debug "^4.4.0" + depd "^2.0.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" merge-descriptors "^2.0.0" - methods "~1.1.2" mime-types "^3.0.0" - on-finished "2.4.1" - once "1.4.0" - parseurl "~1.3.3" - proxy-addr "~2.0.7" - qs "6.13.0" - range-parser "~1.2.1" - router "^2.0.0" - safe-buffer "5.2.1" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" send "^1.1.0" - serve-static "^2.1.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "^2.0.0" - utils-merge "1.0.1" - vary "~1.1.2" - -finalhandler@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.0.0.tgz#9d3c79156dfa798069db7de7dd53bc37546f564b" - integrity sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ== + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz#a2c517a6559852bcdb06d1f8bd7f51b68fad8099" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" forwarded@0.2.0: version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fresh@2.0.0: +fresh@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" + resolved "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== -fresh@^0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - function-bind@^1.1.2: version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" es-errors "^1.3.0" + es-object-atoms "^1.1.1" function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" -gopd@^1.0.1: +get-proto@^1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" - integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== - dependencies: - get-intrinsic "^1.1.3" - -has-property-descriptors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== dependencies: - es-define-property "^1.0.0" + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" -has-proto@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" - integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -has-symbols@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== -hasown@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== +hasown@^2.0.2: + version "2.0.4" + resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== dependencies: function-bind "^1.1.2" -http-errors@2.0.0, http-errors@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -iconv-lite@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.2.tgz#af6d628dccfb463b7364d97f715e4b74b8c8c2b8" - integrity sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag== +http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== dependencies: - safer-buffer ">= 2.1.2 < 3" + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" -iconv-lite@0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== +iconv-lite@^0.7.2, iconv-lite@~0.7.0: + version "0.7.3" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f" + integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -inherits@2.0.4: +inherits@~2.0.4: version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== ipaddr.js@1.9.1: version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== -is-promise@4.0.0: +is-promise@^4.0.0: version "4.0.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + resolved "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== media-typer@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" + resolved "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== merge-descriptors@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= - -mime-db@1.40.0: - version "1.40.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32" - integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-db@^1.53.0: - version "1.53.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" - integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== - -mime-types@^2.1.35: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime-types@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.0.tgz#148453a900475522d095a445355c074cca4f5217" - integrity sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w== - dependencies: - mime-db "^1.53.0" +mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== -mime-types@~2.1.24: - version "2.1.24" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" - integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== +mime-types@^3.0.0, mime-types@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== dependencies: - mime-db "1.40.0" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + mime-db "^1.54.0" ms@^2.1.3: version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== negotiator@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" + resolved "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== -object-inspect@^1.13.1: - version "1.13.2" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.2.tgz#dea0088467fb991e67af4058147a24824a3043ff" - integrity sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g== +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== -on-finished@2.4.1, on-finished@^2.4.1: +on-finished@^2.4.1: version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== dependencies: ee-first "1.1.1" -once@1.4.0: +once@^1.4.0: version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== dependencies: wrappy "1" -parseurl@^1.3.3, parseurl@~1.3.3: +openai@^6.45.0: + version "6.45.0" + resolved "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz#53840c5c5848884dfbff47006f839b27d1d955b9" + integrity sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw== + +parseurl@^1.3.3: version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== path-to-regexp@^8.0.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.1.0.tgz#4d687606ed0be8ed512ba802eb94d620cb1a86f0" - integrity sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ== + version "8.4.2" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" + integrity sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== -proxy-addr@~2.0.7: +proxy-addr@^2.0.7: version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + resolved "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== dependencies: forwarded "0.2.0" ipaddr.js "1.9.1" -qs@6.13.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" - integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== +qs@^6.14.0, qs@^6.15.2: + version "6.15.3" + resolved "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" + integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== dependencies: - side-channel "^1.0.6" + es-define-property "^1.0.1" + side-channel "^1.1.1" -range-parser@^1.2.1, range-parser@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== +range-parser@^1.2.1: + version "1.3.0" + resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz#d7f19be812bb62721472b45d3be219ef09572b47" + integrity sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw== -raw-body@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.0.tgz#25b3476f07a51600619dae3fe82ddc28a36e5e0f" - integrity sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g== +raw-body@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.6.3" - unpipe "1.0.0" + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" -router@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/router/-/router-2.0.0.tgz#8692720b95de83876870d7bc638dd3c7e1ae8a27" - integrity sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ== +router@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" + integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== dependencies: - array-flatten "3.0.0" - is-promise "4.0.0" - methods "~1.1.2" - parseurl "~1.3.3" + debug "^4.4.0" + depd "^2.0.0" + is-promise "^4.0.0" + parseurl "^1.3.3" path-to-regexp "^8.0.0" - setprototypeof "1.2.0" - utils-merge "1.0.1" -safe-buffer@5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -send@^1.0.0, send@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/send/-/send-1.1.0.tgz#4efe6ff3bb2139b0e5b2648d8b18d4dec48fc9c5" - integrity sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA== +send@^1.1.0, send@^1.2.0: + version "1.2.1" + resolved "https://registry.npmjs.org/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" + integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== dependencies: - debug "^4.3.5" - destroy "^1.2.0" + debug "^4.4.3" encodeurl "^2.0.0" escape-html "^1.0.3" etag "^1.8.1" - fresh "^0.5.2" - http-errors "^2.0.0" - mime-types "^2.1.35" + fresh "^2.0.0" + http-errors "^2.0.1" + mime-types "^3.0.2" ms "^2.1.3" on-finished "^2.4.1" range-parser "^1.2.1" - statuses "^2.0.1" + statuses "^2.0.2" -serve-static@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.1.0.tgz#1b4eacbe93006b79054faa4d6d0a501d7f0e84e2" - integrity sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA== +serve-static@^2.2.0: + version "2.2.1" + resolved "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9" + integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== dependencies: encodeurl "^2.0.0" escape-html "^1.0.3" parseurl "^1.3.3" - send "^1.0.0" + send "^1.2.0" -set-function-length@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" - integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== +setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== dependencies: - define-data-property "^1.1.4" es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" + object-inspect "^1.13.4" -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" -side-channel@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" - integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== dependencies: - call-bind "^1.0.7" + call-bound "^1.0.2" es-errors "^1.3.0" - get-intrinsic "^1.2.4" - object-inspect "^1.13.1" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" -statuses@2.0.1, statuses@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +side-channel@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== -toidentifier@1.0.1: +toidentifier@~1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + resolved "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -type-is@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.0.tgz#7d249c2e2af716665cc149575dadb8b3858653af" - integrity sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw== +type-is@^2.0.1, type-is@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570" + integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== dependencies: - content-type "^1.0.5" + content-type "^2.0.0" media-typer "^1.1.0" mime-types "^3.0.0" -type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - -unpipe@1.0.0, unpipe@~1.0.0: +unpipe@~1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= - -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= + resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== -vary@~1.1.2: +vary@^1.1.2: version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= + resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== wrappy@1: version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==