mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-08 16:21:40 +00:00
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:
Generated
+4
-4
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user