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 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, {
@@ -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
+ });
@@ -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
- return ok(await client.listProjects(), "Fetched projects");
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applaunchflow",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Hosted OAuth MCP connector for AppLaunchFlow.",
5
5
  "license": "MIT",
6
6
  "repository": {