feat: private app config to use app urls + app routing (#2587)
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled

* feat: private app config to use app urls

* fix: launch app

* fix: cookie origin
This commit is contained in:
Daniel Salazar
2026-03-03 18:34:33 -08:00
committed by GitHub
parent 3cd5268379
commit 911c163fc8
13 changed files with 989 additions and 168 deletions
+14
View File
@@ -140,6 +140,20 @@ export interface ExtensionEventTypeMap {
checkedBy?: string;
};
};
'app.privateAccess.resolveLaunch': {
appUid: string;
appName?: string;
userUid?: string | null;
source?: string;
args?: Record<string, unknown>;
result: {
hasAccess: boolean;
fallbackAppName?: string;
fallbackArgs?: Record<string, unknown>;
reason?: string;
checkedBy?: string;
};
};
'ai.prompt.validate': {
actor: Actor;
actor,
@@ -0,0 +1,160 @@
/*
* Copyright (C) 2026-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { UserActorType } from '../../services/auth/Actor.js';
const DEFAULT_FALLBACK_APP_NAME = 'app-center';
function isPrivateApp (app) {
return Number(app?.is_private ?? 0) > 0;
}
function buildFallbackPath (appName) {
if ( typeof appName !== 'string' || !appName.trim() ) {
return '/app';
}
return `/app/${encodeURIComponent(appName.trim())}`;
}
function buildDefaultDeniedDecision (appName, reason) {
return {
hasAccess: false,
fallbackAppName: DEFAULT_FALLBACK_APP_NAME,
fallbackArgs: {
path: buildFallbackPath(appName),
},
reason: reason ?? 'private-access-required',
checkedBy: 'core/private-launch-access',
};
}
function normalizeLaunchDecision (decision, appName) {
if ( !decision || typeof decision !== 'object' ) {
return buildDefaultDeniedDecision(appName, 'invalid-private-access-result');
}
const hasAccess = !!decision.hasAccess;
if ( hasAccess ) {
return {
hasAccess: true,
reason: typeof decision.reason === 'string'
? decision.reason
: undefined,
checkedBy: typeof decision.checkedBy === 'string'
? decision.checkedBy
: undefined,
};
}
const fallbackAppName = typeof decision.fallbackAppName === 'string'
&& decision.fallbackAppName.trim()
? decision.fallbackAppName.trim()
: DEFAULT_FALLBACK_APP_NAME;
const fallbackPath = decision.fallbackArgs?.path;
const fallbackArgs = typeof fallbackPath === 'string' && fallbackPath.trim()
? { path: fallbackPath.trim() }
: { path: buildFallbackPath(appName) };
return {
hasAccess: false,
fallbackAppName,
fallbackArgs,
reason: typeof decision.reason === 'string'
? decision.reason
: undefined,
checkedBy: typeof decision.checkedBy === 'string'
? decision.checkedBy
: undefined,
};
}
function getActorUserUid (actor) {
if ( ! actor ) return null;
if ( actor.type instanceof UserActorType ) {
const userUid = actor.type?.user?.uuid;
return typeof userUid === 'string' && userUid ? userUid : null;
}
if ( typeof actor.get_related_actor === 'function' ) {
try {
const userActor = actor.get_related_actor(UserActorType);
const userUid = userActor?.type?.user?.uuid;
return typeof userUid === 'string' && userUid ? userUid : null;
} catch {
return null;
}
}
return null;
}
async function resolvePrivateLaunchAccess ({
app,
services,
userUid,
source,
args,
}) {
if ( ! isPrivateApp(app) ) {
return {
hasAccess: true,
checkedBy: 'core/public-app',
};
}
const deniedDecision = buildDefaultDeniedDecision(
app?.name,
'private-access-required',
);
const eventService = services?.get?.('event');
if ( ! eventService ) {
return {
...deniedDecision,
reason: 'private-access-event-service-unavailable',
};
}
const eventPayload = {
appUid: app?.uid,
appName: app?.name,
userUid: typeof userUid === 'string' && userUid ? userUid : null,
source: source ?? 'unknown',
args: args ?? {},
result: { ...deniedDecision },
};
try {
await eventService.emit('app.privateAccess.resolveLaunch', eventPayload);
} catch {
return {
...deniedDecision,
reason: 'private-access-check-error',
};
}
return normalizeLaunchDecision(eventPayload.result, app?.name);
}
export {
getActorUserUid,
isPrivateApp,
resolvePrivateLaunchAccess,
};
+104 -41
View File
@@ -29,6 +29,13 @@ const { Eq, Like, Or, And } = require('../query/query');
const { BaseES } = require('./BaseES');
const uuidv4 = require('uuid').v4;
let privateLaunchAccessModulePromise;
const getPrivateLaunchAccessModule = async () => {
if ( ! privateLaunchAccessModulePromise ) {
privateLaunchAccessModulePromise = import('../../modules/apps/privateLaunchAccess.js');
}
return privateLaunchAccessModulePromise;
};
class AppES extends BaseES {
static METHODS = {
@@ -159,8 +166,10 @@ class AppES extends BaseES {
// Remove old file associations (if applicable)
if ( extra.old_entity ) {
await this.db.write('DELETE FROM app_filetype_association WHERE app_id = ?',
[insert_id]);
await this.db.write(
'DELETE FROM app_filetype_association WHERE app_id = ?',
[insert_id],
);
}
// Add file associations (if applicable)
@@ -199,8 +208,10 @@ class AppES extends BaseES {
};
await svc_event.emit('app.new-icon', event);
if ( typeof event.url === 'string' && event.url ) {
this.db.write('UPDATE apps SET icon = ? WHERE id = ? LIMIT 1',
[event.url, insert_id]);
this.db.write(
'UPDATE apps SET icon = ? WHERE id = ? LIMIT 1',
[event.url, insert_id],
);
await entity.set('icon', event.url);
}
}
@@ -222,8 +233,10 @@ class AppES extends BaseES {
// Associate app with subdomain (if applicable)
if ( subdomain_id ) {
await this.db.write('UPDATE subdomains SET associated_app_id = ? WHERE id = ?',
[insert_id, subdomain_id]);
await this.db.write(
'UPDATE subdomains SET associated_app_id = ? WHERE id = ?',
[insert_id, subdomain_id],
);
}
if ( extra.old_entity ) {
const svc_event = this.context.get('services').get('event');
@@ -291,8 +304,10 @@ class AppES extends BaseES {
await svc_event.emit('app.new-icon', event);
if ( typeof event.url !== 'string' || !event.url ) return;
await this.db.write('UPDATE apps SET icon = ? WHERE uid = ? LIMIT 1',
[event.url, app_uid]);
await this.db.write(
'UPDATE apps SET icon = ? WHERE uid = ? LIMIT 1',
[event.url, app_uid],
);
}).catch(e => {
const svc_error = this.context.get('services').get('error-service');
svc_error.report('AppES:queue_icon_migration', { source: e });
@@ -306,31 +321,77 @@ class AppES extends BaseES {
* @param {Object} entity - App entity to transform
*/
async read_transform (entity) {
// Add file associations
const rows = await this.db.read('SELECT type FROM app_filetype_association WHERE app_id = ?',
[entity.private_meta.mysql_id]);
entity.set('filetype_associations', rows.map(row => row.type));
const {
getActorUserUid,
resolvePrivateLaunchAccess,
} = await getPrivateLaunchAccessModule();
const services = this.context.get('services');
const actor = Context.get('actor');
const esParams = Context.get('es_params') ?? {};
const appUid = await entity.get('uid');
const appName = await entity.get('name');
const appIndexUrl = await entity.get('index_url');
const appCreatedAt = await entity.get('created_at');
const appIsPrivate = await entity.get('is_private');
const svc_appInformation = this.context.get('services').get('app-information');
const stats = await svc_appInformation.get_stats(await entity.get('uid'), { period: Context.get('es_params')?.stats_period, grouping: Context.get('es_params')?.stats_grouping, created_at: await entity.get('created_at') });
entity.set('stats', stats);
const appInformationService = services.get('app-information');
const authService = services.get('auth');
const statsPromise = appInformationService
? appInformationService.get_stats(appUid, {
period: esParams.stats_period,
grouping: esParams.stats_grouping,
created_at: appCreatedAt,
})
: Promise.resolve(undefined);
const fileAssociationsPromise = this.db.read(
'SELECT type FROM app_filetype_association WHERE app_id = ?',
[entity.private_meta.mysql_id],
);
const createdFromOriginPromise = (async () => {
if ( ! authService ) return null;
try {
const origin = origin_from_url(appIndexUrl);
const expectedUid = await authService.app_uid_from_origin(origin);
return expectedUid === appUid ? origin : null;
} catch {
// This happens when index_url is not a valid URL.
return null;
}
})();
const privateAccessPromise = resolvePrivateLaunchAccess({
app: {
uid: appUid,
name: appName,
is_private: appIsPrivate,
},
services,
userUid: getActorUserUid(actor),
source: 'driverRead',
args: esParams,
});
const [
fileAssociationRows,
stats,
createdFromOrigin,
privateAccess,
] = await Promise.all([
fileAssociationsPromise,
statsPromise,
createdFromOriginPromise,
privateAccessPromise,
]);
await entity.set(
'filetype_associations',
fileAssociationRows.map(row => row.type),
);
await entity.set('stats', stats);
await entity.set('created_from_origin', createdFromOrigin);
await entity.set('privateAccess', privateAccess);
// Migrate b64 icons to the filesystem-backed icon flow without blocking reads.
this.queueIconMigration(entity);
entity.set('created_from_origin', await (async () => {
const svc_auth = this.context.get('services').get('auth');
try {
const origin = origin_from_url(await entity.get('index_url'));
const expected_uid = await svc_auth.app_uid_from_origin(origin);
return expected_uid === await entity.get('uid')
? origin : null ;
} catch (e) {
// This happens when the index_url is not a valid URL
return null;
}
})());
// Check if the user is the owner
const is_owner = await (async () => {
let owner = await entity.get('owner');
@@ -386,22 +447,24 @@ class AppES extends BaseES {
).fetchEntry();
const subdomain = await entity.get('subdomain');
const user = Context.get('user');
let subdomain_res = await this.db.write(`INSERT ${this.db.case({
mysql: 'IGNORE',
sqlite: 'OR IGNORE',
})} INTO subdomains
let subdomain_res = await this.db.write(
`INSERT ${this.db.case({
mysql: 'IGNORE',
sqlite: 'OR IGNORE',
})} INTO subdomains
(subdomain, user_id, root_dir_id, uuid) VALUES
( ?, ?, ?, ?)`,
[
[
//subdomain
subdomain,
//user_id
user.id,
//root_dir_id
(await entity.get('source_directory')).mysql_id,
//uuid, `sd` stands for subdomain
`sd-${ uuidv4()}`,
]);
subdomain,
//user_id
user.id,
//root_dir_id
(await entity.get('source_directory')).mysql_id,
//uuid, `sd` stands for subdomain
`sd-${ uuidv4()}`,
],
);
subdomain_id = subdomain_res.insertId;
}
+4
View File
@@ -96,6 +96,10 @@ module.exports = {
type: 'json',
sql: { ignore: true },
},
privateAccess: {
type: 'json',
sql: { ignore: true },
},
created_from_origin: {
type: 'string',
sql: { ignore: true },
+114 -80
View File
@@ -24,108 +24,142 @@ const config = require('../config');
const { get_apps } = require('../helpers');
const { DB_READ } = require('../services/database/consts.js');
const subdomain = require('../middleware/subdomain.js');
let privateLaunchAccessModulePromise;
const getPrivateLaunchAccessModule = async () => {
if ( ! privateLaunchAccessModulePromise ) {
privateLaunchAccessModulePromise = import('../modules/apps/privateLaunchAccess.js');
}
return privateLaunchAccessModulePromise;
};
// -----------------------------------------------------------------------//
// GET /apps
// -----------------------------------------------------------------------//
router.get('/apps',
subdomain('api'),
auth,
express.json({ limit: '50mb' }),
async (req, res) => {
// /!\ open brace on end of previous line
router.get(
'/apps',
subdomain('api'),
auth,
express.json({ limit: '50mb' }),
async (req, res) => {
// /!\ open brace on end of previous line
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
const db = req.services.get('database').get(DB_READ, 'apps');
const db = req.services.get('database').get(DB_READ, 'apps');
let apps_res = await db.read('SELECT * FROM apps WHERE owner_user_id = ? ORDER BY timestamp DESC',
[req.user.id]);
let apps_res = await db.read(
'SELECT * FROM apps WHERE owner_user_id = ? ORDER BY timestamp DESC',
[req.user.id],
);
const svc_appInformation = req.services.get('app-information');
const svc_appInformation = req.services.get('app-information');
let apps = [];
let apps = [];
if ( apps_res.length > 0 ) {
for ( let i = 0; i < apps_res.length; i++ ) {
// filetype associations
let ftassocs = await db.read('SELECT * FROM app_filetype_association WHERE app_id = ?',
[apps_res[i].id]);
if ( apps_res.length > 0 ) {
for ( let i = 0; i < apps_res.length; i++ ) {
// filetype associations
let ftassocs = await db.read(
'SELECT * FROM app_filetype_association WHERE app_id = ?',
[apps_res[i].id],
);
let filetype_associations = [];
if ( ftassocs.length > 0 ) {
ftassocs.forEach(ftassoc => {
filetype_associations.push(ftassoc.type);
});
}
let filetype_associations = [];
if ( ftassocs.length > 0 ) {
ftassocs.forEach(ftassoc => {
filetype_associations.push(ftassoc.type);
});
}
const stats = await svc_appInformation.get_stats(apps_res[i].uid);
const stats = await svc_appInformation.get_stats(apps_res[i].uid);
apps.push({
uid: apps_res[i].uid,
name: apps_res[i].name,
description: apps_res[i].description,
title: apps_res[i].title,
icon: apps_res[i].icon,
index_url: apps_res[i].index_url,
godmode: apps_res[i].godmode,
background: apps_res[i].background,
maximize_on_start: apps_res[i].maximize_on_start,
filetype_associations: filetype_associations,
...stats,
approved_for_incentive_program: apps_res[i].approved_for_incentive_program,
created_at: apps_res[i].timestamp,
});
}
}
return res.send(apps);
apps.push({
uid: apps_res[i].uid,
name: apps_res[i].name,
description: apps_res[i].description,
title: apps_res[i].title,
icon: apps_res[i].icon,
index_url: apps_res[i].index_url,
godmode: apps_res[i].godmode,
background: apps_res[i].background,
maximize_on_start: apps_res[i].maximize_on_start,
filetype_associations: filetype_associations,
...stats,
approved_for_incentive_program: apps_res[i].approved_for_incentive_program,
created_at: apps_res[i].timestamp,
});
}
}
return res.send(apps);
},
);
// -----------------------------------------------------------------------//
// GET /apps/:name(s)
// -----------------------------------------------------------------------//
router.get('/apps/:name',
subdomain('api'),
auth,
express.json({ limit: '50mb' }),
async (req, res, next) => {
// /!\ open brace on end of previous line
router.get(
'/apps/:name',
subdomain('api'),
auth,
express.json({ limit: '50mb' }),
async (req, res, next) => {
// /!\ open brace on end of previous line
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check subdomain
if ( require('../helpers').subdomain(req) !== 'api' )
{
next();
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
// check if user is verified
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
{
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
}
let app_names = req.params.name.split('|');
const apps = await get_apps(app_names.map(name => ({ name })));
const {
getActorUserUid,
resolvePrivateLaunchAccess,
} = await getPrivateLaunchAccessModule();
let app_names = req.params.name.split('|');
const apps = await get_apps(app_names.map(name => ({ name })));
const actorUserUid = getActorUserUid(req.actor) || req.user?.uuid || null;
const privateAccessDecisions = await Promise.all(apps.map(app => {
if ( ! app ) return Promise.resolve(null);
return resolvePrivateLaunchAccess({
app,
services: req.services,
userUid: actorUserUid,
source: 'appsRoute',
args: req.query ?? {},
});
}));
const final_obj = apps.map((app) => {
if ( ! app ) return null;
return {
uuid: app.uid,
name: app.name,
title: app.title,
icon: app.icon,
godmode: app.godmode,
background: app.background,
maximize_on_start: app.maximize_on_start,
index_url: app.index_url,
};
}).filter(Boolean);
const final_obj = apps.map((app, index) => {
if ( ! app ) return null;
return {
uuid: app.uid,
name: app.name,
title: app.title,
icon: app.icon,
godmode: app.godmode,
background: app.background,
maximize_on_start: app.maximize_on_start,
index_url: app.index_url,
privateAccess: privateAccessDecisions[index] ?? {
hasAccess: true,
checkedBy: 'core/apps-route-default',
},
};
}).filter(Boolean);
return res.send(final_obj);
});
return res.send(final_obj);
},
);
module.exports = router;
@@ -41,6 +41,7 @@ const {
origin: originUrl,
cookie_name: cookieName,
private_app_hosting_domain: privateAppHostingDomain,
private_app_hosting_domain_alt: privateAppHostingDomainAlt,
static_hosting_base_domain_redirect: staticHostingBaseDomainRedirect,
static_hosting_domain: staticHostingDomain,
static_hosting_domain_alt: staticHostingDomainAlt,
@@ -61,29 +62,58 @@ function isPrivateApp (app) {
return Number(app?.is_private ?? 0) > 0;
}
function hostMatchesPrivateDomain (hostname) {
const privateHostingDomain = `${privateAppHostingDomain ?? 'puter.dev'}`
.trim()
.toLowerCase()
.replace(/^\./, '');
if ( ! privateHostingDomain ) return false;
function normalizeConfiguredHostname (hostValue) {
if ( typeof hostValue !== 'string' ) return null;
const normalizedHost = hostValue.trim().toLowerCase().replace(/^\./, '');
if ( ! normalizedHost ) return null;
try {
return new URL(`http://${normalizedHost}`).hostname.toLowerCase();
} catch {
return normalizedHost.split(':')[0] || null;
}
}
const host = `${hostname ?? ''}`.trim().toLowerCase();
function getPrivateHostingDomainsForMatch () {
const domains = new Set();
for ( const candidate of [
privateAppHostingDomain,
privateAppHostingDomainAlt,
'puter.app',
] ) {
const normalizedCandidate = normalizeConfiguredHostname(candidate);
if ( normalizedCandidate ) {
domains.add(normalizedCandidate);
}
}
return [...domains];
}
function getPrivateHostingDomainForRedirect () {
const primaryDomainCandidate = normalizeConfiguredHost(privateAppHostingDomain);
if ( primaryDomainCandidate ) return primaryDomainCandidate;
const altDomainCandidate = normalizeConfiguredHost(privateAppHostingDomainAlt);
if ( altDomainCandidate ) return altDomainCandidate;
return 'puter.app';
}
function hostMatchesPrivateDomain (hostname) {
const host = normalizeConfiguredHostname(hostname);
if ( ! host ) return false;
return host === privateHostingDomain || host.endsWith(`.${privateHostingDomain}`);
const privateHostingDomains = getPrivateHostingDomainsForMatch();
return privateHostingDomains.some(privateHostingDomain =>
host === privateHostingDomain || host.endsWith(`.${privateHostingDomain}`));
}
function getSubdomainFromHostedRequest (req) {
const host = `${req.hostname ?? ''}`.trim().toLowerCase();
const host = normalizeConfiguredHostname(req.hostname);
if ( ! host ) return '';
const privateHostingDomain = `${privateAppHostingDomain ?? 'puter.dev'}`
.trim()
.toLowerCase()
.replace(/^\./, '');
if ( privateHostingDomain ) {
const privateHostingDomains = getPrivateHostingDomainsForMatch()
.sort((a, b) => b.length - a.length);
for ( const privateHostingDomain of privateHostingDomains ) {
const privateDomainSuffix = `.${privateHostingDomain}`;
if ( host === privateHostingDomain ) {
return '';
@@ -103,10 +133,7 @@ function buildPrivateHostRedirectUrl (req, app) {
}
try {
const privateHostingDomain = `${privateAppHostingDomain ?? 'puter.dev'}`
.trim()
.toLowerCase()
.replace(/^\./, '');
const privateHostingDomain = getPrivateHostingDomainForRedirect();
if ( ! privateHostingDomain ) {
return null;
}
@@ -167,6 +194,7 @@ function buildPrivateAppIndexUrlCandidates (req) {
const staticHostingDomainCandidate = normalizeConfiguredHost(staticHostingDomain);
const staticHostingDomainAltCandidate = normalizeConfiguredHost(staticHostingDomainAlt);
const privateHostingDomainCandidate = normalizeConfiguredHost(privateAppHostingDomain);
const privateHostingDomainAltCandidate = normalizeConfiguredHost(privateAppHostingDomainAlt);
if ( staticHostingDomainCandidate ) {
hostCandidates.add(`${hostedSubdomain}.${staticHostingDomainCandidate}`);
@@ -177,6 +205,9 @@ function buildPrivateAppIndexUrlCandidates (req) {
if ( privateHostingDomainCandidate ) {
hostCandidates.add(`${hostedSubdomain}.${privateHostingDomainCandidate}`);
}
if ( privateHostingDomainAltCandidate ) {
hostCandidates.add(`${hostedSubdomain}.${privateHostingDomainAltCandidate}`);
}
}
const candidates = [];
@@ -230,6 +261,42 @@ function getPrivateDeniedRedirectUrl (app, denyRedirectUrl) {
return '/';
}
function getMarketplaceAppUrl (app) {
const appName = typeof app?.name === 'string'
? app.name.trim()
: '';
if ( ! appName ) return null;
const origin = `${originUrl ?? ''}`.trim().replace(/\/$/, '');
if ( ! origin ) return null;
return `${origin}/app/${encodeURIComponent(appName)}/`;
}
function appendLinkHeader (res, linkValue) {
if ( ! linkValue ) return;
const existingValue = typeof res.get === 'function'
? res.get('Link')
: (
typeof res.getHeader === 'function'
? res.getHeader('Link')
: undefined
);
const setHeader = typeof res.set === 'function'
? (value) => res.set('Link', value)
: (
typeof res.setHeader === 'function'
? (value) => res.setHeader('Link', value)
: null
);
if ( ! setHeader ) return;
if ( ! existingValue ) {
setHeader(linkValue);
return;
}
setHeader(`${existingValue}, ${linkValue}`);
}
function isPrivateAccessGateEnabled () {
return config.enable_private_app_access_gate !== false;
}
@@ -435,7 +502,21 @@ function respondPrivateLoginBootstrap ({ res, app }) {
typeof app?.name === 'string' && app.name.trim()
? app.name.trim()
: 'this app';
const appTitle = typeof app?.title === 'string' && app.title.trim()
? app.title.trim()
: appName;
const appDescription = typeof app?.description === 'string' && app.description.trim()
? app.description.trim()
: `${appTitle} requires Puter authentication before private files can load.`;
const appIcon = typeof app?.icon === 'string' && app.icon.trim()
? app.icon.trim()
: null;
const marketplaceAppUrl = getMarketplaceAppUrl(app);
const safeAppName = escapeHtml(appName);
const safeAppTitle = escapeHtml(appTitle);
const safeAppDescription = escapeHtml(appDescription);
const safeMarketplaceAppUrl = escapeHtml(marketplaceAppUrl ?? '');
const safeAppIcon = escapeHtml(appIcon ?? '');
const loginHtml = dedent(`
<!doctype html>
@@ -443,7 +524,19 @@ function respondPrivateLoginBootstrap ({ res, app }) {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sign In Required</title>
<title>Sign In Required | ${safeAppTitle}</title>
<meta name="description" content="${safeAppDescription}" />
<meta name="robots" content="noindex,nofollow" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Sign In Required | ${safeAppTitle}" />
<meta property="og:description" content="${safeAppDescription}" />
${safeMarketplaceAppUrl ? `<meta property="og:url" content="${safeMarketplaceAppUrl}" />` : ''}
${safeAppIcon ? `<meta property="og:image" content="${safeAppIcon}" />` : ''}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Sign In Required | ${safeAppTitle}" />
<meta name="twitter:description" content="${safeAppDescription}" />
${safeAppIcon ? `<meta name="twitter:image" content="${safeAppIcon}" />` : ''}
${safeMarketplaceAppUrl ? `<link rel="canonical" href="${safeMarketplaceAppUrl}" />` : ''}
<style>
:root { color-scheme: light; }
body {
@@ -599,6 +692,11 @@ function respondPrivateLoginBootstrap ({ res, app }) {
res.status(200);
res.set('Cache-Control', 'no-store');
res.set('X-Robots-Tag', 'noindex, nofollow');
appendLinkHeader(
res,
marketplaceAppUrl ? `<${marketplaceAppUrl}>; rel="canonical"` : null,
);
res.set('Content-Type', 'text/html; charset=UTF-8');
return res.send(loginHtml);
}
@@ -665,6 +763,11 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath
hasPrivateCookie: identity.hasPrivateCookie,
hasInvalidPrivateCookie: identity.hasInvalidPrivateCookie,
});
const marketplaceAppUrl = getMarketplaceAppUrl(app);
appendLinkHeader(
res,
marketplaceAppUrl ? `<${marketplaceAppUrl}>; rel="alternate"` : null,
);
res.redirect(redirectUrl);
return false;
}
@@ -680,7 +783,9 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath
res.cookie(
authService.getPrivateAssetCookieName(),
privateToken,
authService.getPrivateAssetCookieOptions(),
authService.getPrivateAssetCookieOptions({
requestHostname: req.hostname,
}),
);
}
@@ -784,6 +889,13 @@ async function runInternal (req, res, next) {
requestPath: req.path,
redirectUrl: privateHostRedirect,
});
const marketplaceAppUrl = getMarketplaceAppUrl(privateApp);
appendLinkHeader(
res,
marketplaceAppUrl
? `<${marketplaceAppUrl}>; rel="alternate"`
: null,
);
return res.redirect(privateHostRedirect);
}
return res.status(403).send('Private app host mismatch');
@@ -33,6 +33,7 @@ vi.mock('../../config.js', () => ({
static_hosting_domain: 'site.puter.localhost',
static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
private_app_hosting_domain: 'puter.dev',
private_app_hosting_domain_alt: 'puter.dev',
enable_private_app_access_gate: true,
origin: 'https://puter.com',
cookie_name: 'puter.session.token',
@@ -41,6 +42,7 @@ vi.mock('../../config.js', () => ({
static_hosting_domain: 'site.puter.localhost',
static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
private_app_hosting_domain: 'puter.dev',
private_app_hosting_domain_alt: 'puter.dev',
enable_private_app_access_gate: true,
origin: 'https://puter.com',
cookie_name: 'puter.session.token',
@@ -132,6 +134,8 @@ describe('PuterSiteMiddleware', () => {
beforeEach(() => {
vi.clearAllMocks();
config.enable_private_app_access_gate = true;
config.private_app_hosting_domain = 'puter.dev';
config.private_app_hosting_domain_alt = 'puter.dev';
Context.get = vi.fn().mockReturnValue(mockContextInstance);
getUserMockImpl = async () => null;
getAppMockImpl = async () => null;
@@ -314,6 +318,96 @@ describe('PuterSiteMiddleware', () => {
expect(mockNext).not.toHaveBeenCalled();
});
it('accepts private app host matching the configured alt private domain', async () => {
config.private_app_hosting_domain = 'app.puter.localhost:4100';
config.private_app_hosting_domain_alt = 'puter.dev';
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockResolvedValue({
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: ['paid'],
is_custom_domain: false,
baseUrl: '',
path: '/index.html',
originalUrl: '/index.html',
query: {},
cookies: {},
headers: {},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
set: vi.fn().mockReturnThis(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(mockRes.redirect).not.toHaveBeenCalledWith(
expect.stringContaining('app.puter.localhost:4100'),
);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('Sign in required'));
expect(mockNext).not.toHaveBeenCalled();
});
it('serves login bootstrap html when private app identity is missing', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
@@ -529,6 +623,8 @@ describe('PuterSiteMiddleware', () => {
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('https://js.puter.com/v2/'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('puter.auth.signIn()'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('meta property="og:title"'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('/app/paid-app/'));
expect(mockNext).not.toHaveBeenCalled();
});
@@ -742,6 +838,124 @@ describe('PuterSiteMiddleware', () => {
expect(mockNext).not.toHaveBeenCalled();
});
it('passes request hostname to private asset cookie options on allow', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = true;
});
const rootDirectoryNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
};
const missingFileNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(false),
get: vi.fn().mockResolvedValue(null),
};
let filesystemNodeCallCount = 0;
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockResolvedValue({
type: {},
get_related_actor: vi.fn().mockReturnValue({
type: {
user: { uuid: 'user-allow-111' },
session: 'session-allow-111',
},
}),
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({ sameSite: 'none' }),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js',
cookies: {
'puter.session.token': 'session-token',
},
headers: {},
query: {},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.getPrivateAssetCookieOptions).toHaveBeenCalledWith({
requestHostname: 'paid.puter.dev',
});
expect(mockRes.cookie).toHaveBeenCalledWith(
'puter.private.asset.token',
'private-token',
{ sameSite: 'none' },
);
expect(mockNext).not.toHaveBeenCalled();
});
it('accepts nested query token key for bootstrap auth', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
+63 -11
View File
@@ -287,7 +287,66 @@ class AuthService extends BaseService {
return DEFAULT_PRIVATE_APP_ASSET_COOKIE_NAME;
}
getPrivateAssetCookieOptions ({ ttlSeconds } = {}) {
normalizeHostnameForCookieDomain (hostnameValue) {
if ( typeof hostnameValue !== 'string' ) return null;
const trimmedHostname = hostnameValue.trim().toLowerCase().replace(/^\./, '');
if ( ! trimmedHostname ) return null;
try {
return new URL(`http://${trimmedHostname}`).hostname.toLowerCase();
} catch {
return trimmedHostname.split(':')[0] || null;
}
}
isCookieDomainHostEligible (hostnameValue) {
if ( typeof hostnameValue !== 'string' || !hostnameValue ) return false;
if ( hostnameValue === 'localhost' ) return false;
if ( hostnameValue.includes(':') ) return false;
if ( ! hostnameValue.includes('.') ) return false;
if ( /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostnameValue) ) return false;
return true;
}
getConfiguredPrivateCookieDomains () {
const configuredDomains = [];
for ( const configuredDomainCandidate of [
this.global_config.private_app_hosting_domain,
this.global_config.private_app_hosting_domain_alt,
] ) {
const normalizedDomain = this.normalizeHostnameForCookieDomain(configuredDomainCandidate);
if ( normalizedDomain ) {
configuredDomains.push(normalizedDomain);
}
}
return [...new Set(configuredDomains)];
}
resolvePrivateAssetCookieDomain ({ requestHostname } = {}) {
const configuredDomains = this.getConfiguredPrivateCookieDomains();
const normalizedRequestHost = this.normalizeHostnameForCookieDomain(requestHostname);
if ( normalizedRequestHost ) {
const matchedConfiguredDomain = configuredDomains
.sort((domainA, domainB) => domainB.length - domainA.length)
.find(configuredDomain =>
normalizedRequestHost === configuredDomain ||
normalizedRequestHost.endsWith(`.${configuredDomain}`));
if ( this.isCookieDomainHostEligible(matchedConfiguredDomain) ) {
return `.${matchedConfiguredDomain}`;
}
return undefined;
}
const normalizedConfiguredPrimaryDomain = this.normalizeHostnameForCookieDomain(
this.global_config.private_app_hosting_domain,
);
if ( this.isCookieDomainHostEligible(normalizedConfiguredPrimaryDomain) ) {
return `.${normalizedConfiguredPrimaryDomain}`;
}
return undefined;
}
getPrivateAssetCookieOptions ({ ttlSeconds, requestHostname } = {}) {
const effectiveTtlSeconds = this.resolvePositiveInteger(
ttlSeconds,
this.getPrivateAssetTokenTtlSeconds(),
@@ -301,16 +360,9 @@ class AuthService extends BaseService {
maxAge: effectiveTtlSeconds * 1000,
};
const privateHostingDomain = `${this.global_config.private_app_hosting_domain ?? ''}`
.trim()
.toLowerCase()
.replace(/^\./, '');
if (
privateHostingDomain &&
privateHostingDomain !== 'localhost' &&
!privateHostingDomain.includes(':')
) {
cookieOptions.domain = `.${privateHostingDomain}`;
const cookieDomain = this.resolvePrivateAssetCookieDomain({ requestHostname });
if ( cookieDomain ) {
cookieOptions.domain = cookieDomain;
}
return cookieOptions;
@@ -8,6 +8,7 @@ type AuthServiceForPrivateTokenTests = AuthService & {
private_app_asset_token_ttl_seconds: number;
private_app_asset_cookie_name: string;
private_app_hosting_domain: string;
private_app_hosting_domain_alt?: string;
};
modules: {
jwt: {
@@ -28,6 +29,7 @@ const createAuthService = (): AuthServiceForPrivateTokenTests => {
private_app_asset_token_ttl_seconds: 3600,
private_app_asset_cookie_name: 'puter.private.asset.token',
private_app_hosting_domain: 'app.puter.localhost',
private_app_hosting_domain_alt: 'puter.dev',
};
authService.modules = {
jwt: {
@@ -110,6 +112,30 @@ describe('AuthService private asset token helpers', () => {
expect(options.domain).toBe('.app.puter.localhost');
});
it('uses the matched request host private domain when provided', () => {
const authService = createAuthService();
authService.global_config.private_app_hosting_domain = 'app.puter.localhost';
authService.global_config.private_app_hosting_domain_alt = 'puter.dev';
const options = authService.getPrivateAssetCookieOptions({
requestHostname: 'beans.puter.dev',
});
expect(options.domain).toBe('.puter.dev');
});
it('omits domain when request host does not match configured private domains', () => {
const authService = createAuthService();
authService.global_config.private_app_hosting_domain = 'puter.app';
authService.global_config.private_app_hosting_domain_alt = 'puter.app';
const options = authService.getPrivateAssetCookieOptions({
requestHostname: 'beans.puter.dev',
});
expect(options.domain).toBeUndefined();
});
it('resolves bootstrap identity from app-under-user token without app lookup', async () => {
const authService = createAuthService();
const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0';
+7
View File
@@ -32,6 +32,13 @@ Arguments to pass to the app.
## Return value
A `Promise` that will resolve to an [`AppConnection`](/Objects/AppConnection) once the app is launched.
When private-access routing applies, the resolved connection may include
`connection.response.launchResult` with fields such as:
- `requestedAppName`
- `openedAppName`
- `redirectedToFallback`
- `deniedPrivateAccess`
## Examples
```html
+116 -4
View File
@@ -21,6 +21,43 @@ import path from '../lib/path.js';
import { PROCESS_IPC_ATTACHED, PROCESS_RUNNING, PortalProcess, PseudoProcess } from '../definitions.js';
import UIWindow from '../UI/UIWindow.js';
const normalizePrivateAccessDecision = (privateAccess) => {
if ( !privateAccess || typeof privateAccess !== 'object' ) {
return null;
}
return {
hasAccess: !!privateAccess.hasAccess,
fallbackAppName: typeof privateAccess.fallbackAppName === 'string'
? privateAccess.fallbackAppName.trim()
: '',
fallbackArgs: privateAccess.fallbackArgs &&
typeof privateAccess.fallbackArgs === 'object' &&
!Array.isArray(privateAccess.fallbackArgs)
? privateAccess.fallbackArgs
: {},
reason: typeof privateAccess.reason === 'string'
? privateAccess.reason
: undefined,
};
};
const getLaunchResult = (launchOutcome) => {
if ( !launchOutcome || typeof launchOutcome !== 'object' ) {
return null;
}
if ( launchOutcome.launchResult && typeof launchOutcome.launchResult === 'object' ) {
return launchOutcome.launchResult;
}
return null;
};
const endLaunchTransaction = (transaction) => {
if ( transaction ) {
transaction.end();
}
};
/**
* Launches an app.
*
@@ -39,6 +76,7 @@ const launch_app = async (options) => {
const uuid = options.uuid ?? window.uuidv4();
let icon, title, file_signature;
const window_options = options.window_options ?? {};
const privateLaunchRedirectDepth = Number(options.privateLaunchRedirectDepth ?? 0);
if ( options.parent_instance_id ) {
window_options.parent_instance_id = options.parent_instance_id;
@@ -66,6 +104,70 @@ const launch_app = async (options) => {
// If no `options.name` is provided, use the app name from the app_info
options.name = options.name ?? app_info.name;
const requestedAppName = options.privateLaunchRequestedAppName ?? options.name ?? app_info.name ?? null;
const privateAccessDecision = normalizePrivateAccessDecision(app_info.privateAccess);
if ( privateAccessDecision && privateAccessDecision.hasAccess === false ) {
const fallbackAppName = privateAccessDecision.fallbackAppName;
const fallbackArgs = privateAccessDecision.fallbackArgs ?? {};
if ( fallbackAppName && privateLaunchRedirectDepth < 1 && fallbackAppName !== options.name ) {
const fallbackLaunchOutcome = await launch_app({
...options,
name: fallbackAppName,
args: fallbackArgs,
app_obj: undefined,
privateLaunchRequestedAppName: requestedAppName,
privateLaunchRedirectDepth: privateLaunchRedirectDepth + 1,
});
const fallbackLaunchResult = getLaunchResult(fallbackLaunchOutcome) ?? {
launched: true,
requestedAppName,
openedAppName: fallbackAppName,
redirectedToFallback: true,
deniedPrivateAccess: true,
privateAccess: privateAccessDecision,
};
const redirectedLaunchResult = {
...fallbackLaunchResult,
requestedAppName,
redirectedToFallback: true,
deniedPrivateAccess: true,
privateAccess: privateAccessDecision,
};
if ( fallbackLaunchOutcome && typeof fallbackLaunchOutcome === 'object' ) {
fallbackLaunchOutcome.launchResult = redirectedLaunchResult;
}
endLaunchTransaction(transaction);
return fallbackLaunchOutcome ?? { launchResult: redirectedLaunchResult };
}
const deniedAppTitle = app_info.title ?? app_info.name ?? options.name ?? 'this app';
const safeDeniedAppTitle = window.html_encode
? window.html_encode(deniedAppTitle)
: deniedAppTitle;
if ( typeof window.UIAlert === 'function' ) {
await window.UIAlert(`You don't have access to ${safeDeniedAppTitle}.`);
} else {
window.alert(`You don't have access to ${deniedAppTitle}.`);
}
const deniedLaunchResult = {
launched: false,
requestedAppName,
openedAppName: null,
appInstanceID: null,
appUid: null,
redirectedToFallback: false,
deniedPrivateAccess: true,
privateAccess: privateAccessDecision,
};
endLaunchTransaction(transaction);
return { launchResult: deniedLaunchResult };
}
//-----------------------------------
// icon
@@ -527,11 +629,21 @@ const launch_app = async (options) => {
svc_process.unregister(process.uuid);
});
process.launchResult = {
launched: true,
requestedAppName,
openedAppName: (options.name === 'trash') ? 'explorer' : options.name,
appInstanceID: process.uuid ?? uuid,
appUid: (options.name === 'explorer' || options.name === 'trash')
? null
: (app_info.uuid ?? app_info.uid ?? null),
redirectedToFallback: privateLaunchRedirectDepth > 0,
deniedPrivateAccess: false,
privateAccess: privateAccessDecision ?? undefined,
};
// end the transaction
if ( transaction )
{
transaction.end();
}
endLaunchTransaction(transaction);
return process;
};
+15 -10
View File
@@ -61,8 +61,6 @@ export class ExecService extends Service {
target: child_instance_id,
}) : undefined;
this.log.info('launchApp connection', connection);
const params = {};
for ( const provider of this.param_providers ) {
Object.assign(params, provider());
@@ -116,15 +114,12 @@ export class ExecService extends Service {
// Check if file_paths are provided and caller has godmode permissions
if ( file_paths && Array.isArray(file_paths) && file_paths.length > 0 && process ) {
try {
console.log('file_paths', file_paths);
// Get caller app info to check godmode status
const caller_app_name = process.name;
const caller_app_info = await window.get_apps(caller_app_name);
// Check if caller is in godmode
if ( caller_app_info && caller_app_info.godmode === 1 ) {
this.log.info(`⚠️ GODMODE app ${caller_app_name} launching ${app_name} with files:`, file_paths);
// Get target app info to create file signatures
const target_app_info = await puter.apps.get(app_name);
@@ -162,18 +157,27 @@ export class ExecService extends Service {
}
}
} else {
console.log(`⚠️ App ${caller_app_name} attempted to launch ${app_name} with files but does not have godmode permissions`);
// Continue with normal launch, ignoring file_paths
}
} catch ( error ) {
console.log('Error checking godmode permissions:', error);
this.log.warn('Error checking godmode permissions', error);
// Continue with normal launch
}
}
// The "body" of this method is in a separate file
const child_process = await launch_app(launch_options);
const child_launch_outcome = await launch_app(launch_options);
const launchResult = child_launch_outcome?.launchResult;
const child_process = child_launch_outcome?.references?.iframe
? child_launch_outcome
: null;
if ( ! child_process ) {
return {
appInstanceID: launchResult?.appInstanceID ?? null,
usesSDK: false,
...(launchResult ? { response: { launchResult } } : {}),
};
}
const send_child_launched_msg = (...a) => {
if ( ! process ) return;
@@ -223,6 +227,7 @@ export class ExecService extends Service {
appInstanceID: connection ?
connection.forward.uuid : child_instance_id,
usesSDK: true,
...(launchResult ? { response: { launchResult } } : {}),
};
}
+19 -1
View File
@@ -79,11 +79,29 @@ export interface AppConnectionCloseEvent {
statusCode?: number;
}
export interface LaunchAppResult {
launched: boolean;
requestedAppName?: string | null;
openedAppName?: string | null;
appInstanceID?: string | null;
appUid?: string | null;
redirectedToFallback?: boolean;
deniedPrivateAccess?: boolean;
privateAccess?: {
hasAccess: boolean;
fallbackAppName?: string;
fallbackArgs?: Record<string, unknown>;
reason?: string;
};
}
export type CancelAwarePromise<T> = Promise<T> & { undefinedOnCancel?: Promise<T | undefined> };
export class AppConnection {
readonly usesSDK: boolean;
readonly response?: Record<string, unknown>;
readonly response?: Record<string, unknown> & {
launchResult?: LaunchAppResult;
};
on (eventName: 'message', handler: (message: unknown) => void): void;
on (eventName: 'close', handler: (data: AppConnectionCloseEvent) => void): void;