applaunchflow 0.3.5 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/http.js +14 -0
- package/build/http.test.js +35 -0
- package/build/projects.test.js +103 -0
- package/build/tools/projects.js +133 -3
- package/package.json +1 -1
package/build/http.js
CHANGED
|
@@ -148,6 +148,20 @@ export function createHttpServer() {
|
|
|
148
148
|
json(response, 200, { ok: true, service: "applaunchflow-mcp" });
|
|
149
149
|
return;
|
|
150
150
|
}
|
|
151
|
+
if (request.method === "GET" &&
|
|
152
|
+
url.pathname === "/.well-known/openai-apps-challenge") {
|
|
153
|
+
const token = process.env.OPENAI_APPS_CHALLENGE_TOKEN?.trim();
|
|
154
|
+
if (!token) {
|
|
155
|
+
json(response, 404, { error: "Challenge token not configured" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
response.writeHead(200, {
|
|
159
|
+
"content-type": "text/plain; charset=utf-8",
|
|
160
|
+
"cache-control": "no-store",
|
|
161
|
+
});
|
|
162
|
+
response.end(token);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
151
165
|
if (request.method === "GET" &&
|
|
152
166
|
url.pathname === "/.well-known/oauth-protected-resource") {
|
|
153
167
|
json(response, 200, {
|
package/build/http.test.js
CHANGED
|
@@ -32,6 +32,41 @@ test("HTTP server exposes health and protected-resource metadata", async () => {
|
|
|
32
32
|
]);
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
|
+
test("HTTP server exposes the configured OpenAI domain challenge", async () => {
|
|
36
|
+
const previousToken = process.env.OPENAI_APPS_CHALLENGE_TOKEN;
|
|
37
|
+
process.env.OPENAI_APPS_CHALLENGE_TOKEN = "openai-domain-challenge";
|
|
38
|
+
try {
|
|
39
|
+
await withServer(async (baseUrl) => {
|
|
40
|
+
const response = await fetch(`${baseUrl}/.well-known/openai-apps-challenge`);
|
|
41
|
+
assert.equal(response.status, 200);
|
|
42
|
+
assert.equal(response.headers.get("content-type"), "text/plain; charset=utf-8");
|
|
43
|
+
assert.equal(await response.text(), "openai-domain-challenge");
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
if (previousToken === undefined) {
|
|
48
|
+
delete process.env.OPENAI_APPS_CHALLENGE_TOKEN;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
process.env.OPENAI_APPS_CHALLENGE_TOKEN = previousToken;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
test("OpenAI domain challenge returns 404 when it is not configured", async () => {
|
|
56
|
+
const previousToken = process.env.OPENAI_APPS_CHALLENGE_TOKEN;
|
|
57
|
+
delete process.env.OPENAI_APPS_CHALLENGE_TOKEN;
|
|
58
|
+
try {
|
|
59
|
+
await withServer(async (baseUrl) => {
|
|
60
|
+
const response = await fetch(`${baseUrl}/.well-known/openai-apps-challenge`);
|
|
61
|
+
assert.equal(response.status, 404);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (previousToken !== undefined) {
|
|
66
|
+
process.env.OPENAI_APPS_CHALLENGE_TOKEN = previousToken;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
35
70
|
test("MCP endpoint challenges unauthenticated callers with resource metadata", async () => {
|
|
36
71
|
await withServer(async (baseUrl) => {
|
|
37
72
|
const response = await fetch(`${baseUrl}/mcp`, {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { toProjectSummary, toSafeProjectState } from "./tools/projects.js";
|
|
4
|
+
const rawProject = {
|
|
5
|
+
id: "00000000-0000-4000-8000-000000000001",
|
|
6
|
+
user_id: "private-user-id",
|
|
7
|
+
name: "LaunchNotes Demo",
|
|
8
|
+
platform: "ios",
|
|
9
|
+
created_at: "2026-08-01T10:00:00.000Z",
|
|
10
|
+
updated_at: "2026-08-12T10:00:00.000Z",
|
|
11
|
+
metadata: {
|
|
12
|
+
appName: "LaunchNotes Demo",
|
|
13
|
+
category: "Productivity",
|
|
14
|
+
defaultDeviceType: "phone",
|
|
15
|
+
detectedLanguage: "en",
|
|
16
|
+
logoPath: "private/logo.png",
|
|
17
|
+
logoUrl: "https://storage.example/private-logo?token=secret",
|
|
18
|
+
screenshotCache: { firstMobilePath: "private/screenshot.png" },
|
|
19
|
+
},
|
|
20
|
+
aso_copy: { keywords: "private keyword payload" },
|
|
21
|
+
};
|
|
22
|
+
test("project summaries contain only workflow-relevant public fields", () => {
|
|
23
|
+
assert.deepEqual(toProjectSummary(rawProject), {
|
|
24
|
+
id: rawProject.id,
|
|
25
|
+
name: "LaunchNotes Demo",
|
|
26
|
+
platform: "ios",
|
|
27
|
+
updatedAt: "2026-08-12T10:00:00.000Z",
|
|
28
|
+
category: "Productivity",
|
|
29
|
+
defaultDeviceType: "phone",
|
|
30
|
+
detectedLanguage: "en",
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
test("safe project state removes internal records and signed asset URLs", () => {
|
|
34
|
+
const state = toSafeProjectState({
|
|
35
|
+
project: { ...rawProject, logoUrl: "https://storage.example/logo?token=secret" },
|
|
36
|
+
assets: {
|
|
37
|
+
mobileScreenshots: ["https://storage.example/one?token=secret"],
|
|
38
|
+
tabletScreenshots: [],
|
|
39
|
+
desktopScreenshots: ["https://storage.example/two?token=secret"],
|
|
40
|
+
logo: "https://storage.example/logo?token=secret",
|
|
41
|
+
qrCodes: [{ signedUrl: "https://storage.example/qr?token=secret" }],
|
|
42
|
+
},
|
|
43
|
+
content: {
|
|
44
|
+
screenshots: {
|
|
45
|
+
type: "screenshots",
|
|
46
|
+
variants: [
|
|
47
|
+
{
|
|
48
|
+
id: "variant-id",
|
|
49
|
+
generation_id: "generation-id",
|
|
50
|
+
content_type: "screenshots",
|
|
51
|
+
label: "v1",
|
|
52
|
+
is_active: true,
|
|
53
|
+
created_at: "2026-08-01T10:00:00.000Z",
|
|
54
|
+
updated_at: "2026-08-12T10:00:00.000Z",
|
|
55
|
+
languages: ["en"],
|
|
56
|
+
ready: true,
|
|
57
|
+
config: { internal: true },
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
activeVariant: {
|
|
61
|
+
id: "variant-id",
|
|
62
|
+
generation_id: "generation-id",
|
|
63
|
+
is_active: true,
|
|
64
|
+
ready: true,
|
|
65
|
+
},
|
|
66
|
+
editUrl: "https://dashboard.applaunchflow.com/editor?id=safe",
|
|
67
|
+
variantCount: 1,
|
|
68
|
+
isReady: true,
|
|
69
|
+
translations: [{ raw: "private layout" }],
|
|
70
|
+
},
|
|
71
|
+
asoCopy: { asoCopy: { keywords: "private" } },
|
|
72
|
+
appIcon: { variants: [{ config: "private" }] },
|
|
73
|
+
},
|
|
74
|
+
progress: {
|
|
75
|
+
totalItems: 4,
|
|
76
|
+
completedItems: 1,
|
|
77
|
+
percentage: 25,
|
|
78
|
+
missingItems: ["promoVideo"],
|
|
79
|
+
},
|
|
80
|
+
readyItems: { "internal-generation-id": true },
|
|
81
|
+
});
|
|
82
|
+
assert.deepEqual(state.assets, {
|
|
83
|
+
screenshotCounts: { mobile: 1, tablet: 0, desktop: 1 },
|
|
84
|
+
hasLogo: true,
|
|
85
|
+
qrCodeCount: 1,
|
|
86
|
+
});
|
|
87
|
+
assert.deepEqual(Object.keys(state.content), ["screenshots"]);
|
|
88
|
+
const serialized = JSON.stringify(state);
|
|
89
|
+
for (const forbidden of [
|
|
90
|
+
"private-user-id",
|
|
91
|
+
"token=secret",
|
|
92
|
+
"logoPath",
|
|
93
|
+
"screenshotCache",
|
|
94
|
+
"aso_copy",
|
|
95
|
+
"translations",
|
|
96
|
+
"readyItems",
|
|
97
|
+
"created_at",
|
|
98
|
+
"content_type",
|
|
99
|
+
"config",
|
|
100
|
+
]) {
|
|
101
|
+
assert.equal(serialized.includes(forbidden), false, forbidden);
|
|
102
|
+
}
|
|
103
|
+
});
|
package/build/tools/projects.js
CHANGED
|
@@ -3,13 +3,143 @@ import { fail, ok } from "./utils.js";
|
|
|
3
3
|
function stripUndefined(value) {
|
|
4
4
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
5
5
|
}
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function asArray(value) {
|
|
10
|
+
return Array.isArray(value) ? value : [];
|
|
11
|
+
}
|
|
12
|
+
function safeHttpsUrl(value) {
|
|
13
|
+
if (typeof value !== "string")
|
|
14
|
+
return undefined;
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(value);
|
|
17
|
+
return url.protocol === "https:" ? value : undefined;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function toProjectSummary(value) {
|
|
24
|
+
const project = isRecord(value) ? value : {};
|
|
25
|
+
const metadata = isRecord(project.metadata) ? project.metadata : {};
|
|
26
|
+
return stripUndefined({
|
|
27
|
+
id: typeof project.id === "string" ? project.id : undefined,
|
|
28
|
+
name: typeof project.name === "string"
|
|
29
|
+
? project.name
|
|
30
|
+
: typeof metadata.appName === "string"
|
|
31
|
+
? metadata.appName
|
|
32
|
+
: undefined,
|
|
33
|
+
platform: typeof project.platform === "string"
|
|
34
|
+
? project.platform
|
|
35
|
+
: typeof metadata.platform === "string"
|
|
36
|
+
? metadata.platform
|
|
37
|
+
: undefined,
|
|
38
|
+
updatedAt: typeof project.updated_at === "string"
|
|
39
|
+
? project.updated_at
|
|
40
|
+
: typeof project.updatedAt === "string"
|
|
41
|
+
? project.updatedAt
|
|
42
|
+
: undefined,
|
|
43
|
+
category: typeof metadata.category === "string" ? metadata.category : undefined,
|
|
44
|
+
defaultDeviceType: typeof metadata.defaultDeviceType === "string"
|
|
45
|
+
? metadata.defaultDeviceType
|
|
46
|
+
: undefined,
|
|
47
|
+
detectedLanguage: typeof metadata.detectedLanguage === "string"
|
|
48
|
+
? metadata.detectedLanguage
|
|
49
|
+
: undefined,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function toVariantSummary(value) {
|
|
53
|
+
const variant = isRecord(value) ? value : {};
|
|
54
|
+
return stripUndefined({
|
|
55
|
+
id: typeof variant.id === "string" ? variant.id : undefined,
|
|
56
|
+
generationId: typeof variant.generation_id === "string"
|
|
57
|
+
? variant.generation_id
|
|
58
|
+
: typeof variant.generationId === "string"
|
|
59
|
+
? variant.generationId
|
|
60
|
+
: undefined,
|
|
61
|
+
label: typeof variant.label === "string" ? variant.label : undefined,
|
|
62
|
+
isActive: typeof variant.is_active === "boolean"
|
|
63
|
+
? variant.is_active
|
|
64
|
+
: typeof variant.isActive === "boolean"
|
|
65
|
+
? variant.isActive
|
|
66
|
+
: undefined,
|
|
67
|
+
updatedAt: typeof variant.updated_at === "string"
|
|
68
|
+
? variant.updated_at
|
|
69
|
+
: typeof variant.updatedAt === "string"
|
|
70
|
+
? variant.updatedAt
|
|
71
|
+
: undefined,
|
|
72
|
+
languages: Array.isArray(variant.languages)
|
|
73
|
+
? variant.languages.filter((entry) => typeof entry === "string")
|
|
74
|
+
: undefined,
|
|
75
|
+
ready: typeof variant.ready === "boolean" ? variant.ready : undefined,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function toContentSummary(value) {
|
|
79
|
+
const content = isRecord(value) ? value : {};
|
|
80
|
+
const variants = asArray(content.variants).map(toVariantSummary);
|
|
81
|
+
const activeVariant = isRecord(content.activeVariant)
|
|
82
|
+
? toVariantSummary(content.activeVariant)
|
|
83
|
+
: undefined;
|
|
84
|
+
return stripUndefined({
|
|
85
|
+
type: typeof content.type === "string" ? content.type : undefined,
|
|
86
|
+
variants,
|
|
87
|
+
activeVariant,
|
|
88
|
+
editUrl: safeHttpsUrl(content.editUrl),
|
|
89
|
+
activeLabel: typeof content.activeLabel === "string" ? content.activeLabel : undefined,
|
|
90
|
+
variantCount: typeof content.variantCount === "number"
|
|
91
|
+
? content.variantCount
|
|
92
|
+
: variants.length,
|
|
93
|
+
isReady: typeof content.isReady === "boolean" ? content.isReady : undefined,
|
|
94
|
+
hasContent: typeof content.hasContent === "boolean" ? content.hasContent : undefined,
|
|
95
|
+
screenshotTemplateId: typeof content.screenshotTemplateId === "string"
|
|
96
|
+
? content.screenshotTemplateId
|
|
97
|
+
: undefined,
|
|
98
|
+
socialTemplateId: typeof content.socialTemplateId === "string"
|
|
99
|
+
? content.socialTemplateId
|
|
100
|
+
: undefined,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function toSafeProjectState(value) {
|
|
104
|
+
const state = isRecord(value) ? value : {};
|
|
105
|
+
const assets = isRecord(state.assets) ? state.assets : {};
|
|
106
|
+
const content = isRecord(state.content) ? state.content : {};
|
|
107
|
+
const progress = isRecord(state.progress) ? state.progress : {};
|
|
108
|
+
const safeContent = Object.fromEntries(["screenshots", "socialGraphics", "promoVideo", "mockups"]
|
|
109
|
+
.filter((key) => isRecord(content[key]))
|
|
110
|
+
.map((key) => [key, toContentSummary(content[key])]));
|
|
111
|
+
return {
|
|
112
|
+
project: toProjectSummary(state.project),
|
|
113
|
+
assets: {
|
|
114
|
+
screenshotCounts: {
|
|
115
|
+
mobile: asArray(assets.mobileScreenshots).length,
|
|
116
|
+
tablet: asArray(assets.tabletScreenshots).length,
|
|
117
|
+
desktop: asArray(assets.desktopScreenshots).length,
|
|
118
|
+
},
|
|
119
|
+
hasLogo: typeof assets.logo === "string" && assets.logo.length > 0,
|
|
120
|
+
qrCodeCount: asArray(assets.qrCodes).length,
|
|
121
|
+
},
|
|
122
|
+
content: safeContent,
|
|
123
|
+
progress: stripUndefined({
|
|
124
|
+
totalItems: typeof progress.totalItems === "number" ? progress.totalItems : undefined,
|
|
125
|
+
completedItems: typeof progress.completedItems === "number"
|
|
126
|
+
? progress.completedItems
|
|
127
|
+
: undefined,
|
|
128
|
+
percentage: typeof progress.percentage === "number" ? progress.percentage : undefined,
|
|
129
|
+
missingItems: Array.isArray(progress.missingItems)
|
|
130
|
+
? progress.missingItems.filter((entry) => typeof entry === "string")
|
|
131
|
+
: undefined,
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
6
135
|
export function registerProjectTools(server, client) {
|
|
7
136
|
server.registerTool("list_projects", {
|
|
8
137
|
title: "List Projects",
|
|
9
138
|
description: "List AppLaunchFlow projects for the authenticated user",
|
|
10
139
|
}, async () => {
|
|
11
140
|
try {
|
|
12
|
-
|
|
141
|
+
const result = await client.listProjects();
|
|
142
|
+
return ok({ projects: asArray(result.projects).map(toProjectSummary) }, "Fetched projects");
|
|
13
143
|
}
|
|
14
144
|
catch (error) {
|
|
15
145
|
return fail(error);
|
|
@@ -23,7 +153,7 @@ export function registerProjectTools(server, client) {
|
|
|
23
153
|
},
|
|
24
154
|
}, async ({ projectId }) => {
|
|
25
155
|
try {
|
|
26
|
-
return ok(await client.getProject(projectId), "Fetched project");
|
|
156
|
+
return ok(toSafeProjectState(await client.getProject(projectId)), "Fetched project");
|
|
27
157
|
}
|
|
28
158
|
catch (error) {
|
|
29
159
|
return fail(error);
|
|
@@ -88,7 +218,7 @@ export function registerProjectTools(server, client) {
|
|
|
88
218
|
});
|
|
89
219
|
const created = await client.createProject(requestBody);
|
|
90
220
|
return ok({
|
|
91
|
-
project: created.project,
|
|
221
|
+
project: toProjectSummary(created.project),
|
|
92
222
|
nextRecommendedStep: "upload_screenshots",
|
|
93
223
|
}, "Created project");
|
|
94
224
|
}
|