refactor: move subscriptions to webhook system (#3193)

* fix: dont sign write urls if they don't have write perm

* wip: new subscriptions

* refactor: move subscriptions to
This commit is contained in:
Daniel Salazar
2026-06-02 17:58:56 -07:00
committed by GitHub
parent 6d2f277ce2
commit d897cc5dd0
9 changed files with 364 additions and 248 deletions
+4 -4
View File
@@ -14210,9 +14210,9 @@
}
},
"node_modules/mysql2": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.3.tgz",
"integrity": "sha512-uWWxvZSRvRhtBdh2CdcuK83YcOfPdmEeEYB069bAmPnV93QApDGVPuvCQOLjlh7tYHEWdgQPrn6kosDxHBVLkA==",
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.4.tgz",
"integrity": "sha512-CtXYlmL7ZamiYKbmqkamQHWJROUHSfm+f3kByzGfknw7kW51mcB2ouMUqYq1XfYxbXmnWo6RhPydx6OCqdgcmQ==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
@@ -18403,7 +18403,7 @@
"lorem-ipsum": "^2.0.8",
"mime-types": "^2.1.35",
"murmurhash": "^2.0.1",
"mysql2": "^3.21.1",
"mysql2": "^3.22.4",
"nodemailer": "^8.0.7",
"openai": "^6.34.0",
"otpauth": "^9.2.4",
@@ -33,7 +33,7 @@ import type { DriverController } from './DriverController.js';
// The DriverController under test is the same instance the live request
// pipeline uses, so its iface→driver registry is populated from real
// drivers (puter-kvstore, puter-apps, puter-subdomains, …). The HTTP
// handlers (`#handleCall`, `#handleListInterfaces`, `#handleXd`) are
// handlers (`#handleCall`, `#handleListInterfaces`) are
// private — we exercise the public lookup API (`resolve` / list / get
// default) which the handlers themselves delegate to.
@@ -116,7 +116,7 @@ describe('DriverController.resolve', () => {
});
});
// ── Route handlers (#handleCall, #handleListInterfaces, #handleXd)
// ── Route handlers (#handleCall, #handleListInterfaces) ────────────
// The handlers are private class fields. We capture references to them by
// invoking `registerRoutes` with a fake router whose `post`/`get` save the
@@ -189,10 +189,7 @@ const makeRes = (): MockRes => {
return res;
};
const makeReq = (
body: Record<string, unknown> = {},
actor?: Actor,
): Request =>
const makeReq = (body: Record<string, unknown> = {}, actor?: Actor): Request =>
({
body,
actor,
@@ -338,11 +335,7 @@ describe('DriverController.#handleCall (via captured router)', () => {
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](
req,
res as unknown as Response,
() => {},
),
routes['POST /call'](req, res as unknown as Response, () => {}),
);
const body = res.body as {
success: boolean;
@@ -374,11 +367,9 @@ describe('DriverController.#handleCall (via captured router)', () => {
// Register it through the controller's private map by re-running
// its #buildIfaceMap path: easier to just stash it into the
// existing iface map directly via TypeScript-defeating cast.
const internalDrivers = (
controller as unknown as {
['#drivers']: Map<string, Map<string, unknown>>;
}
);
const internalDrivers = controller as unknown as {
['#drivers']: Map<string, Map<string, unknown>>;
};
// Access the actual private slot via the well-known getter
// pattern doesn't work for `#`-private fields; instead, re-run
// registerRoutes after stashing on the bag — but that's already
@@ -413,13 +404,12 @@ describe('DriverController.#handleCall (via captured router)', () => {
>;
const original = kv.set;
kv.set = async function (...args: unknown[]) {
const { Context } = await import(
'../../core/context.js'
);
const { Context } = await import('../../core/context.js');
Context.set('driverMetadata', { providerUsed: 'kv-direct' });
return (
original as (...x: unknown[]) => Promise<unknown>
).apply(this, args);
return (original as (...x: unknown[]) => Promise<unknown>).apply(
this,
args,
);
};
try {
const res = makeRes();
@@ -432,11 +422,7 @@ describe('DriverController.#handleCall (via captured router)', () => {
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](
req,
res as unknown as Response,
() => {},
),
routes['POST /call'](req, res as unknown as Response, () => {}),
);
const body = res.body as {
metadata?: Record<string, unknown>;
@@ -497,31 +483,11 @@ describe('DriverController.#handleListInterfaces', () => {
});
});
describe('DriverController.#handleXd', () => {
let routes: Captured;
beforeAll(() => {
routes = captureRoutes(controller);
});
it('serves the iframe-bridge HTML with text/html content-type', () => {
const res = makeRes();
routes['GET /xd'](
makeReq(),
res as unknown as Response,
() => {},
);
expect(res.contentType).toBe('text/html');
expect(res.sentBody).toMatch(/<!DOCTYPE html>/);
expect(res.sentBody).toMatch(/\/drivers\/call/);
});
});
describe('DriverController.registerRoutes', () => {
it('registers POST /call, GET /list-interfaces, and GET /xd', () => {
it('registers POST /call and GET /list-interfaces', () => {
const routes = captureRoutes(controller);
expect(routes['POST /call']).toBeInstanceOf(Function);
expect(routes['GET /list-interfaces']).toBeInstanceOf(Function);
expect(routes['GET /xd']).toBeInstanceOf(Function);
});
});
@@ -26,8 +26,6 @@ import {
checkDriverRateLimit,
} from '../../core/http/middleware/rateLimit.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { PermissionService } from '../../services/permission/PermissionService.js';
import type { WithLifecycle } from '../../types';
import type { DriverMeta } from '../../drivers/meta.js';
import {
isDriverStreamResult,
@@ -35,120 +33,12 @@ import {
resolveDriverMethodConcurrent,
resolveDriverMethodRateLimit,
} from '../../drivers/meta.js';
import type { PermissionService } from '../../services/permission/PermissionService.js';
import type { WithLifecycle } from '../../types';
import { PuterController } from '../types.js';
type DriverInstance = WithLifecycle & Record<string, unknown>;
// -- /drivers/xd payload ---------------------------------------------
//
// Self-contained HTML/JS shipped to the iframe consumer. Listens for
// postMessage events shaped as `{ id, interface, method, params }`,
// forwards to `/drivers/call`, and posts `{ id, result }` back to the
// originating window.
//
// Wire-shape note: the postMessage uses `params` (puter-js's historical
// name) but `/drivers/call` expects `args`; this bridge translates.
const XD_SCRIPT = /* js */ `
(function () {
const call = async ({ interface_name, method_name, params }) => {
const response = await fetch('/drivers/call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
interface: interface_name,
method: method_name,
args: params,
}),
});
return await response.json();
};
const fcall = async ({ interface_name, method_name, params }) => {
const form = new FormData();
form.append('interface', interface_name);
form.append('method', method_name);
for (const k in params) {
form.append(k, params[k]);
}
const response = await fetch('/drivers/call', {
method: 'POST',
body: form,
});
return await response.json();
};
window.addEventListener('message', async (event) => {
const { id, interface: iface, method, params } = event.data || {};
let has_file = false;
for (const k in params) {
if (params[k] instanceof File) {
has_file = true;
break;
}
}
const result = has_file
? await fcall({ interface_name: iface, method_name: method, params })
: await call({ interface_name: iface, method_name: method, params });
if (event.source) {
event.source.postMessage({ id, result }, event.origin);
}
});
})();
`;
const XD_HTML = `<!DOCTYPE html>
<html>
<head>
<title>Puter Driver API</title>
<script>
document.addEventListener('DOMContentLoaded', function () {
${XD_SCRIPT}
});
</script>
</head>
<body></body>
</html>`;
// -- Controller ------------------------------------------------------
/**
* Routes driver RPC calls through a unified HTTP surface.
*
* - `POST /drivers/call` invoke `<iface>.<method>(args)` on the
* registered driver after a per-actor permission + rate-limit check.
* Stream-shaped results are piped directly; everything else is
* returned as JSON.
* - `GET /drivers/list-interfaces` enumerate registered driver
* interfaces with their default + alternate implementations.
* - `GET /drivers/xd` legacy iframe bridge; serves an HTML page that
* proxies `postMessage` RPCs to `/drivers/call` on the same origin.
*
* Holds an internal iface driverName instance map built at
* construction time from `this.drivers`. Extensions that register
* additional drivers end up in that bag before this controller is
* instantiated, so they show up here automatically.
*/
/**
* Catch-all upstream-error translator for the driver boundary.
*
* Drivers that wrap a third-party SDK (OpenAI, Anthropic, etc.) often
* let the SDK's own error class bubble those carry an HTTP `.status`
* but are plain `Error` subclasses, not `HttpError`s, so they would
* otherwise hit the global error handler as unexpected 500s and page
* PagerDuty. Repackage them with `upstream_*` legacy codes so the
* alarm gate (server.ts) treats them as upstream failures and only
* pages on the two we actually care about (rate-limit / auth).
*
* Status extraction covers the shapes we've seen in the wild:
* - `.status` / `.statusCode` (OpenAI / Anthropic / Together / Google GenAI)
* - `.response.status` (Replicate's `ApiError`)
* - `.$metadata.httpStatusCode` (AWS SDK v3, e.g. Polly)
* - status sniffed from the message string (last resort, for providers
* that re-wrap their SDK error in `new Error(msg)` and lose the field)
*
* `HttpError`s thrown by drivers pass through untouched.
*/
const extractUpstreamStatus = (e: {
status?: number;
statusCode?: number;
@@ -273,11 +163,6 @@ export class DriverController extends PuterController {
{ subdomain: 'api', requireAuth: true },
this.#handleListInterfaces,
);
router.get(
'/xd',
{ subdomain: 'api', requireAuth: true },
this.#handleXd,
);
}
// -- Handlers ----------------------------------------------------
@@ -489,11 +374,6 @@ export class DriverController extends PuterController {
res.json(out);
};
#handleXd = (_req: Request, res: Response): void => {
res.type('text/html');
res.send(XD_HTML);
};
// -- Internals ---------------------------------------------------
#buildIfaceMap(): void {
@@ -1089,6 +1089,85 @@ describe('LegacyFSController.openItem', () => {
});
});
// ── openItem: write_url stripping for read-only callers ─────────────
//
// Mirrors `/sign` and `/readdir`, which already strip `write_url` when the
// caller only proved read. Without these gates, /open_item handed out a
// valid write signature to read-only sharees — `/writeFile`'s own ACL
// re-check would still reject the write, but the leak shape (a signed
// write URL escaping the access boundary) is the same one those other
// endpoints already defend against.
describe('LegacyFSController.openItem (write_url stripping)', () => {
beforeAll(() => {
(
controller as unknown as { config: { api_base_url?: string } }
).config.api_base_url = 'http://api.test.local';
});
it('returns write_url for the owner (who has write)', async () => {
const { actor } = await makeUser();
const target = `/${actor.user!.username}/Documents/owned.txt`;
await withActor(actor, () =>
controller.touch(
makeReq({ body: { path: target }, actor }),
makeRes().res,
),
);
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.openItem(
makeReq({ body: { path: target }, actor }),
res,
),
);
const body = captured.body as {
signature: { read_url?: string; write_url?: string };
};
expect(body.signature.read_url).toBeDefined();
expect(body.signature.write_url).toBeDefined();
expect(body.signature.write_url).toContain('writeFile');
});
it('strips write_url for a read-only sharee (the fix)', async () => {
const victim = await makeUser();
const attacker = await makeUser();
const target = `/${victim.actor.user!.username}/Documents/shared-ro.txt`;
await withActor(victim.actor, () =>
controller.touch(
makeReq({ body: { path: target }, actor: victim.actor }),
makeRes().res,
),
);
const entry = await server.stores.fsEntry.getEntryByPath(target);
await server.services.permission.grantUserUserPermission(
victim.actor,
attacker.actor.user!.username!,
`fs:${entry!.uuid}:read`,
{},
);
const { res, captured } = makeRes();
await withActor(attacker.actor, () =>
controller.openItem(
makeReq({
body: { uid: entry!.uuid },
actor: attacker.actor,
}),
res,
),
);
const body = captured.body as {
signature: { read_url?: string; write_url?: string };
};
// Sharee still gets read_url (they have read).
expect(body.signature.read_url).toBeDefined();
// …but write_url is stripped (they don't have write).
expect(body.signature.write_url).toBeUndefined();
});
});
// ── requestAppRootDir ───────────────────────────────────────────────
describe('LegacyFSController.requestAppRootDir', () => {
@@ -1391,6 +1391,20 @@ export class LegacyFSController extends PuterController {
'read',
);
// Downgrade the envelope when the caller only proved read.
// `/writeFile`'s ACL re-check would still block the write, but
// returning `write_url` to a read-only caller is the same
// privilege-leak shape that `/sign` and `/readdir` strip.
const writeOk = await this.services.acl.check(
actor,
{
path: entry.path,
resolveAncestors: () =>
this.services.fs.getAncestorChain(entry.path),
},
'write',
);
const suggested =
(await this.services.suggestedApps?.getSuggestedApps({
name: entry.name,
@@ -1417,7 +1431,13 @@ export class LegacyFSController extends PuterController {
}
const signingCfg = signingConfigFromAppConfig(this.config);
const signature = { ...signEntry(entry, signingCfg), path: entry.path };
const signed = signEntry(entry, signingCfg);
const signature = writeOk
? { ...signed, path: entry.path }
: (() => {
const { write_url: _, ...rest } = signed;
return { ...rest, path: entry.path };
})();
res.json({
signature,
token,
+1 -1
View File
@@ -56,7 +56,7 @@
"lorem-ipsum": "^2.0.8",
"mime-types": "^2.1.35",
"murmurhash": "^2.0.1",
"mysql2": "^3.21.1",
"mysql2": "^3.22.4",
"nodemailer": "^8.0.7",
"openai": "^6.34.0",
"otpauth": "^9.2.4",
+194 -64
View File
@@ -19,17 +19,21 @@
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
function buildRecentAppsHTML () {
function buildRecentAppsHTML() {
let h = '';
if ( window.launch_apps?.recent?.length > 0 ) {
if (window.launch_apps?.recent?.length > 0) {
h += '<div class="bento-recent-apps-grid">';
// Show up to 6 recent apps (2 columns x 3 rows)
const recentApps = window.launch_apps.recent.slice(0, 6);
for ( const app_info of recentApps ) {
for (const app_info of recentApps) {
// if title, name and uuid are the same and index_url is set, then show the hostname of index_url
if ( app_info.name === app_info.title && app_info.name === app_info.uuid && app_info.index_url ) {
if (
app_info.name === app_info.title &&
app_info.name === app_info.uuid &&
app_info.index_url
) {
app_info.title = new URL(app_info.index_url).hostname;
app_info.target_link = app_info.index_url;
}
@@ -44,9 +48,12 @@ function buildRecentAppsHTML () {
h += '</div>';
} else {
h += '<div class="bento-recent-apps-empty">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">';
h += '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>';
h += '<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>';
h +=
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">';
h +=
'<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>';
h +=
'<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>';
h += '</svg>';
h += '<p>No recent apps yet</p>';
h += '<span>Apps you use will appear here</span>';
@@ -56,21 +63,25 @@ function buildRecentAppsHTML () {
return h;
}
function buildUsageHTML () {
function buildUsageHTML() {
let h = '';
h += '<div class="bento-usage-grid">';
// Your Plan section
h += '<div class="bento-usage-section bento-usage-card bento-plan-section">';
h +=
'<div class="bento-usage-section bento-usage-card bento-plan-section">';
h += '<a href="#" class="bento-usage-card-header bento-plan-header">';
h += `<h3>${i18n('your_plan')}</h3>`;
h += '<span class="bento-usage-card-arrow"></span>';
h += '</a>';
h += '<div class="bento-usage-card-info bento-plan-info">';
h += '<span class="bento-usage-card-used bento-plan-name">--</span>';
h += '<span class="bento-usage-card-details bento-plan-details"><span class="bento-plan-badge"></span></span>';
h +=
'<span class="bento-usage-card-details bento-plan-details"><span class="bento-plan-badge"></span></span>';
h += '</div>';
h += '<a href="#" class="bento-plan-upgrade" style="display: none;">Upgrade →</a>';
h += '<div class="bento-plan-warning" style="display: none;"></div>';
h +=
'<a href="#" class="bento-plan-upgrade" style="display: none;">Upgrade →</a>';
h += '</div>';
// Storage section
@@ -83,8 +94,10 @@ function buildUsageHTML () {
h += '<div class="bento-usage-card-bar bento-storage-bar"></div>';
h += '</div>';
h += '<div class="bento-usage-card-info">';
h += '<span class="bento-usage-card-used bento-storage-used">-- Used</span>';
h += '<span class="bento-usage-card-details"><span class="bento-storage-percent">--%</span> of <span class="bento-storage-capacity">--</span></span>';
h +=
'<span class="bento-usage-card-used bento-storage-used">-- Used</span>';
h +=
'<span class="bento-usage-card-details"><span class="bento-storage-percent">--%</span> of <span class="bento-storage-capacity">--</span></span>';
h += '</div>';
h += '</div>';
@@ -98,8 +111,10 @@ function buildUsageHTML () {
h += '<div class="bento-usage-card-bar bento-resources-bar"></div>';
h += '</div>';
h += '<div class="bento-usage-card-info">';
h += '<span class="bento-usage-card-used bento-resources-used">-- Used</span>';
h += '<span class="bento-usage-card-details"><span class="bento-resources-percent">--%</span> of <span class="bento-resources-capacity">--</span></span>';
h +=
'<span class="bento-usage-card-used bento-resources-used">-- Used</span>';
h +=
'<span class="bento-usage-card-details"><span class="bento-resources-percent">--%</span> of <span class="bento-resources-capacity">--</span></span>';
h += '</div>';
h += '</div>';
@@ -112,9 +127,10 @@ const TabHome = {
label: 'Home',
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
html () {
html() {
const username = window.user?.username || 'User';
const profilePicture = window.user?.profile?.picture || window.icons['profile.svg'];
const profilePicture =
window.user?.profile?.picture || window.icons['profile.svg'];
let h = '';
h += '<div class="bento-container">';
@@ -129,9 +145,10 @@ const TabHome = {
h += `<h1 class="bento-username username">${html_encode(username)}</h1>`;
h += `<p class="bento-tagline">${i18n('your_personal_internet_computer')}</p>`;
// Show warning if account is temporary/unsaved
if ( window.user?.is_temp ) {
if (window.user?.is_temp) {
h += '<button class="bento-save-account-warning">';
h += '<svg style="width: 16px; height: 16px;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px" height="48px" viewBox="0 0 48 48"><g transform="translate(0, 0)"><path d="M45.521,39.04L27.527,5.134c-1.021-1.948-3.427-2.699-5.375-1.679-.717,.376-1.303,.961-1.679,1.679L2.479,39.04c-.676,1.264-.635,2.791,.108,4.017,.716,1.207,2.017,1.946,3.42,1.943H41.993c1.403,.003,2.704-.736,3.42-1.943,.743-1.226,.784-2.753,.108-4.017ZM23.032,15h1.937c.565,0,1.017,.467,1,1.031l-.438,14c-.017,.54-.459,.969-1,.969h-1.062c-.54,0-.983-.429-1-.969l-.438-14c-.018-.564,.435-1.031,1-1.031Zm.968,25c-1.657,0-3-1.343-3-3s1.343-3,3-3,3,1.343,3,3-1.343,3-3,3Z" fill="var(--dashboard-warning-icon)"></path></g></svg>';
h +=
'<svg style="width: 16px; height: 16px;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px" height="48px" viewBox="0 0 48 48"><g transform="translate(0, 0)"><path d="M45.521,39.04L27.527,5.134c-1.021-1.948-3.427-2.699-5.375-1.679-.717,.376-1.303,.961-1.679,1.679L2.479,39.04c-.676,1.264-.635,2.791,.108,4.017,.716,1.207,2.017,1.946,3.42,1.943H41.993c1.403,.003,2.704-.736,3.42-1.943,.743-1.226,.784-2.753,.108-4.017ZM23.032,15h1.937c.565,0,1.017,.467,1,1.031l-.438,14c-.017,.54-.459,.969-1,.969h-1.062c-.54,0-.983-.429-1-.969l-.438-14c-.018-.564,.435-1.031,1-1.031Zm.968,25c-1.657,0-3-1.343-3-3s1.343-3,3-3,3,1.343,3,3-1.343,3-3,3Z" fill="var(--dashboard-warning-icon)"></path></g></svg>';
h += `<span>${i18n('save_session')}</span>`;
h += '</button>';
}
@@ -141,14 +158,17 @@ const TabHome = {
// Recent apps card (rectangle)
h += '<div class="bento-card bento-recent">';
h += '<a href="#" class="bento-card-fancy-header" data-target-tab="apps">';
h +=
'<a href="#" class="bento-card-fancy-header" data-target-tab="apps">';
h += '<div class="bento-card-fancy-icon bento-card-fancy-icon-apps">';
h += '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>';
h +=
'<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>';
h += '</div>';
h += '<div class="bento-card-fancy-text">';
h += '<h2>Apps</h2>';
h += '<span class="bento-card-fancy-subtitle">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
h +=
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
h += 'Recently used';
h += '</span>';
h += '</div>';
@@ -168,14 +188,17 @@ const TabHome = {
// Usage card (spans full width on second row)
h += '<div class="bento-card bento-usage">';
h += '<a href="#" class="bento-card-fancy-header" data-target-tab="usage">';
h +=
'<a href="#" class="bento-card-fancy-header" data-target-tab="usage">';
h += '<div class="bento-card-fancy-icon bento-card-fancy-icon-usage">';
h += '<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4M3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707M2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10m9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5m.754-4.246a.39.39 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.39.39 0 0 0-.029-.518z"/><path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A8 8 0 0 1 0 10m8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3"/></svg>';
h +=
'<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4M3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707M2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10m9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5m.754-4.246a.39.39 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.39.39 0 0 0-.029-.518z"/><path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A8 8 0 0 1 0 10m8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3"/></svg>';
h += '</div>';
h += '<div class="bento-card-fancy-text">';
h += `<h2>${i18n('usage')}</h2>`;
h += '<span class="bento-card-fancy-subtitle">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>';
h +=
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>';
h += 'Monthly overview';
h += '</span>';
h += '</div>';
@@ -189,9 +212,33 @@ const TabHome = {
return h;
},
init ($el_window) {
init($el_window) {
this.loadRecentApps($el_window);
this.loadUsageData($el_window);
// Auto-refresh sources:
// 1. `puter:subscription:changed` — fired by upgrade dialog
// after subscribe / change / cancel / uncancel.
// 2. Visibility / focus — user returning to puter (e.g. from
// Stripe Customer Portal in another tab). Portal mutations
// bypass our dispatch, so we re-pull state + broadcast.
const refresh = () => this.loadUsageData($el_window);
const refreshAndBroadcast = async () => {
refresh();
try {
await window.refresh_user_data?.(puter.authToken);
} catch {}
try {
window.dispatchEvent(
new CustomEvent('puter:subscription:changed'),
);
} catch {}
};
window.addEventListener('puter:subscription:changed', refresh);
const onVisibility = () => {
if (document.visibilityState === 'visible') refreshAndBroadcast();
};
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('focus', refreshAndBroadcast);
// Handle app clicks
$el_window.on('click', '.bento-recent-app', function (e) {
@@ -199,23 +246,30 @@ const TabHome = {
e.stopPropagation();
const appName = $(this).attr('data-app-name');
const targetLink = $(this).attr('data-target-link');
if ( targetLink && targetLink !== '' ) {
if (targetLink && targetLink !== '') {
window.open(targetLink, '_blank');
}
else if ( appName ) {
} else if (appName) {
window.open(`/app/${appName}`, '_blank');
}
});
// Handle "View details" link clicks
$el_window.on('click', '.bento-view-more, .bento-usage-card-header, .bento-card-fancy-header[data-target-tab]', function (e) {
e.preventDefault();
const targetTab = $(this).attr('data-target-tab');
if ( targetTab ) {
// Trigger click on the corresponding sidebar item
$el_window.find(`.dashboard-sidebar-item[data-section="${targetTab}"]`).click();
}
});
$el_window.on(
'click',
'.bento-view-more, .bento-usage-card-header, .bento-card-fancy-header[data-target-tab]',
function (e) {
e.preventDefault();
const targetTab = $(this).attr('data-target-tab');
if (targetTab) {
// Trigger click on the corresponding sidebar item
$el_window
.find(
`.dashboard-sidebar-item[data-section="${targetTab}"]`,
)
.click();
}
},
);
// Handle desktop switch button
$el_window.on('click', '.bento-desktop-switch-btn', function () {
@@ -237,15 +291,15 @@ const TabHome = {
draggable_body: false,
},
}).then(function (is_saved) {
if ( is_saved ) {
if (is_saved) {
$el_window.find('.bento-save-account-warning').hide();
}
});
});
},
async loadRecentApps ($el_window) {
if ( ! window.launch_apps?.recent?.length ) {
async loadRecentApps($el_window) {
if (!window.launch_apps?.recent?.length) {
try {
window.launch_apps = await $.ajax({
url: `${window.api_origin}/get-launch-apps?icon_size=64`,
@@ -253,36 +307,89 @@ const TabHome = {
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${window.auth_token}`,
Authorization: `Bearer ${window.auth_token}`,
},
});
} catch (e) {
console.error('Failed to load launch apps:', e);
}
}
$el_window.find('.bento-recent-apps-container').html(buildRecentAppsHTML());
$el_window
.find('.bento-recent-apps-container')
.html(buildRecentAppsHTML());
},
async loadUsageData ($el_window) {
// Load plan data
async loadUsageData($el_window) {
// Load plan data — fetch live from /marketplace/subscriptions/current
// rather than reading `window.user.subscription` (which is set once
// from whoami at page-load and goes stale after subscribe / portal
// cancel until a hard refresh).
try {
const hasSubscription = window.user?.subscription?.active;
const planName = window.user?.subscription?.offering?.name || 'free';
let subscription = null;
try {
const resp = await fetch(
`${window.api_origin}/marketplace/subscriptions/current`,
{
headers: { Authorization: `Bearer ${puter.authToken}` },
},
);
if (resp.ok) {
const data = await resp.json();
subscription = data?.subscription ?? null;
}
} catch {
// fall through to free state
}
const pastDue =
!!subscription && subscription.status === 'past_due';
// `past_due` keeps benefits during Stripe's dunning window, so
// it still counts as having a plan — we just flag it.
const hasSubscription =
!!subscription &&
(subscription.status === 'active' ||
subscription.status === 'trialing' ||
subscription.status === 'cancel_pending' ||
pastDue);
const planName = subscription?.tier || 'free';
$el_window.find('.bento-plan-name').text(i18n(planName));
if ( hasSubscription ) {
$el_window.find('.bento-plan-badge').text('Current').addClass('active');
// Reset state-dependent classes / warning each (re)render.
const $badge = $el_window
.find('.bento-plan-badge')
.removeClass('active free past-due');
const $warning = $el_window
.find('.bento-plan-warning')
.hide()
.text('');
if (hasSubscription) {
if (pastDue) {
$badge.text('Payment past due').addClass('past-due');
$warning
.text(
'Your last payment failed. Update your payment method to keep your subscription — access will be revoked shortly.',
)
.show();
} else {
$badge.text('Current').addClass('active');
}
$el_window.find('.bento-plan-upgrade').text('Manage →').show();
} else {
$el_window.find('.bento-plan-badge').text('Upgrade for more features').addClass('free');
$badge.text('Upgrade for more features').addClass('free');
$el_window.find('.bento-plan-upgrade').show();
}
$el_window.find('.bento-plan-upgrade').off('click.billing').on('click.billing', (e) => {
e.preventDefault();
window.puterLegacyBilling?.openSubscriptionsDialog?.();
});
$el_window
.find('.bento-plan-upgrade')
.off('click.billing')
.on('click.billing', (e) => {
e.preventDefault();
if (typeof window.UIUpgradeAccount === 'function') {
new window.UIUpgradeAccount().open_as_window();
}
});
} catch (e) {
console.error('Failed to load plan data:', e);
}
@@ -290,17 +397,23 @@ const TabHome = {
// Load storage data
try {
const res = await puter.fs.space();
let usage_percentage = (res.used / res.capacity * 100).toFixed(0);
let usage_percentage = ((res.used / res.capacity) * 100).toFixed(0);
usage_percentage = usage_percentage > 100 ? 100 : usage_percentage;
let general_used = res.used;
if ( res.host_used ) {
if (res.host_used) {
general_used = res.host_used;
}
$el_window.find('.bento-storage-used').text(`${window.byte_format(general_used)} Used`);
$el_window.find('.bento-storage-capacity').text(window.byte_format(res.capacity));
$el_window.find('.bento-storage-percent').text(`${usage_percentage}%`);
$el_window
.find('.bento-storage-used')
.text(`${window.byte_format(general_used)} Used`);
$el_window
.find('.bento-storage-capacity')
.text(window.byte_format(res.capacity));
$el_window
.find('.bento-storage-percent')
.text(`${usage_percentage}%`);
$el_window.find('.bento-storage-bar').css({
width: `${usage_percentage}%`,
'background-color': window.usage_bar_color(usage_percentage),
@@ -315,21 +428,38 @@ const TabHome = {
let monthlyAllowance = res.allowanceInfo?.monthUsageAllowance;
let remaining = res.allowanceInfo?.remaining;
let totalUsage = monthlyAllowance - remaining;
let totalUsagePercentage = (totalUsage / monthlyAllowance * 100).toFixed(0);
let totalUsagePercentage = (
(totalUsage / monthlyAllowance) *
100
).toFixed(0);
$el_window.find('.bento-resources-used').text(`${window.number_format(totalUsage / 100_000_000, { decimals: 2, prefix: '$' })} Used`);
$el_window.find('.bento-resources-capacity').text(window.number_format(monthlyAllowance / 100_000_000, { decimals: 2, prefix: '$' }));
$el_window.find('.bento-resources-percent').text(`${totalUsagePercentage}%`);
$el_window
.find('.bento-resources-used')
.text(
`${window.number_format(totalUsage / 100_000_000, { decimals: 2, prefix: '$' })} Used`,
);
$el_window
.find('.bento-resources-capacity')
.text(
window.number_format(monthlyAllowance / 100_000_000, {
decimals: 2,
prefix: '$',
}),
);
$el_window
.find('.bento-resources-percent')
.text(`${totalUsagePercentage}%`);
$el_window.find('.bento-resources-bar').css({
width: `${totalUsagePercentage}%`,
'background-color': window.usage_bar_color(totalUsagePercentage),
'background-color':
window.usage_bar_color(totalUsagePercentage),
});
} catch (e) {
console.error('Failed to load monthly usage data:', e);
}
},
onActivate ($el_window) {
onActivate($el_window) {
this.loadRecentApps($el_window);
this.loadUsageData($el_window);
},
+22
View File
@@ -1473,6 +1473,28 @@ input.myapps-search::placeholder {
border: 1px solid var(--dashboard-success-border);
}
.dashboard .bento-plan-badge.past-due {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
color: var(--dashboard-danger-text);
background: var(--dashboard-danger-background);
border: 1px solid var(--dashboard-danger-border);
}
.dashboard .bento-plan-warning {
margin-top: 10px;
padding: 8px 10px;
border-radius: 8px;
color: var(--dashboard-danger-text);
background: var(--dashboard-danger-background);
border: 1px solid var(--dashboard-danger-border);
font-size: 12px;
line-height: 1.4;
}
.dashboard .bento-plan-upgrade {
font-size: 14px;
font-weight: 500;
+27 -8
View File
@@ -258,17 +258,36 @@ export const transformToV2 = (source) => {
copyIfSet(h, 'gui_css', out);
}
// Legacy billing consolidation (stripe/offerings/__subs-serve → legacyBilling).
const legacyBilling = {};
// Billing / Stripe consolidation. v1 split these across `stripe`,
// `offerings`, and `__subs-serve`; v2 lands everything under the
// `appStoreAndPurchases` extension (puter-paid subscriptions, the
// marketplace, and webhook handling all share one Stripe client).
const appStoreAndPurchases = (out.appStoreAndPurchases ?? {});
if ( svc.stripe ) {
if ( svc.stripe.api_secret ) legacyBilling.api_secret = svc.stripe.api_secret;
if ( svc.stripe.endpoint_secret ) legacyBilling.endpoint_secret = svc.stripe.endpoint_secret;
if ( svc.stripe.api_secret ) appStoreAndPurchases.api_secret = svc.stripe.api_secret;
if ( svc.stripe.endpoint_secret ) appStoreAndPurchases.endpoint_secret = svc.stripe.endpoint_secret;
}
if ( svc['__subs-serve']?.stripe_publishable_key ) {
legacyBilling.stripe_publishable_key = svc['__subs-serve'].stripe_publishable_key;
appStoreAndPurchases.stripe_publishable_key = svc['__subs-serve'].stripe_publishable_key;
}
if ( svc.offerings?.price_ids ) legacyBilling.price_ids = svc.offerings.price_ids;
if ( Object.keys(legacyBilling).length ) out.legacyBilling = legacyBilling;
if ( svc.offerings?.price_ids ) {
appStoreAndPurchases.puterPaid = appStoreAndPurchases.puterPaid ?? {};
appStoreAndPurchases.puterPaid.price_ids = svc.offerings.price_ids;
}
// Forward an already-migrated `legacyBilling` block too, in case the
// input config was migrated against an older version of this tool.
if ( source.legacyBilling ) {
if ( source.legacyBilling.api_secret ) appStoreAndPurchases.api_secret ??= source.legacyBilling.api_secret;
if ( source.legacyBilling.endpoint_secret ) appStoreAndPurchases.endpoint_secret ??= source.legacyBilling.endpoint_secret;
if ( source.legacyBilling.stripe_publishable_key ) {
appStoreAndPurchases.stripe_publishable_key ??= source.legacyBilling.stripe_publishable_key;
}
if ( source.legacyBilling.price_ids ) {
appStoreAndPurchases.puterPaid = appStoreAndPurchases.puterPaid ?? {};
appStoreAndPurchases.puterPaid.price_ids ??= source.legacyBilling.price_ids;
}
}
if ( Object.keys(appStoreAndPurchases).length ) out.appStoreAndPurchases = appStoreAndPurchases;
// Abuse / clickhouse / cf_file_cache pass through if already top-level.
if ( source.abuse ) out.abuse = source.abuse;
@@ -465,7 +484,7 @@ export const transformToV2 = (source) => {
'allow_no_host_header', 'allow_nipio_domains', 'custom_domains_enabled',
'enable_ip_validation',
'default_user_group', 'default_temp_group',
'abuse', 'clickhouse', 'cf_file_cache', 'legacyBilling',
'abuse', 'clickhouse', 'cf_file_cache', 'legacyBilling', 'appStoreAndPurchases',
'providers', 'thumbnailStore',
'redis', 'extension',
...droppedTopKeys,