mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 06:58:21 +00:00
chore: cleanup other puter-js modules to match new structure (#3440)
This commit is contained in:
@@ -7,20 +7,20 @@ import localStorageMemory from './lib/polyfills/localStorage.js';
|
||||
import xhrshim from './lib/polyfills/xhrshim.js';
|
||||
import * as utils from './lib/utils.js';
|
||||
import { AI } from './modules/ai/index.js';
|
||||
import Apps from './modules/Apps.js';
|
||||
import { Apps } from './modules/apps/index.js';
|
||||
import Auth from './modules/Auth.js';
|
||||
import { Debug } from './modules/Debug.js';
|
||||
import Drivers from './modules/Drivers.js';
|
||||
import Email from './modules/Email.js';
|
||||
import { PuterJSFileSystemModule } from './modules/FileSystem/index.js';
|
||||
import FSItem from './modules/FSItem.js';
|
||||
import Hosting from './modules/Hosting.js';
|
||||
import { Hosting } from './modules/hosting/index.js';
|
||||
import { KV } from './modules/kv/index.js';
|
||||
import { PSocket } from './modules/networking/PSocket.js';
|
||||
import { PTLSSocket } from './modules/networking/PTLS.js';
|
||||
import { pFetch } from './modules/networking/requests.js';
|
||||
import OS from './modules/OS.js';
|
||||
import Perms from './modules/Perms.js';
|
||||
import { OS } from './modules/os/index.js';
|
||||
import { Perms } from './modules/perms/index.js';
|
||||
import PuterDialog from './modules/PuterDialog.js';
|
||||
import UI from './modules/UI.js';
|
||||
import Util from './modules/Util.js';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The error puter.js throws for client-side validation failures, and the
|
||||
* canonical shape module code normalizes thrown values into.
|
||||
*
|
||||
* Historically the SDK threw one of two shapes: a plain `{ message, code }`
|
||||
* object (KV, Hosting, ...) or, in a couple of Apps methods, the nested
|
||||
* `{ success: false, error: { code, message } }`. `PuterJSError` is a strict
|
||||
* superset of both so existing `catch` blocks keep working unchanged:
|
||||
*
|
||||
* - it is a real `Error`, so `err instanceof Error`, `err.stack`, and
|
||||
* `err.message` all work (plain objects gave none of these);
|
||||
* - `message` and `code` are own, enumerable properties, so
|
||||
* `JSON.stringify(err)` and `const { code } = err` behave like the old
|
||||
* plain objects did;
|
||||
* - any extra fields (e.g. the legacy `success` / `error` pair) are attached
|
||||
* as own properties, so callers reading `err.error.code` keep working.
|
||||
*
|
||||
* Error codes are API surface — they are `snake_case` and must not change.
|
||||
*/
|
||||
export class PuterJSError extends Error {
|
||||
/**
|
||||
* @param {string} message - Human-readable message.
|
||||
* @param {string} [code] - Stable `snake_case` error code.
|
||||
* @param {Record<string, unknown>} [extra] - Extra own-enumerable fields
|
||||
* to attach (for backward-compatible shapes such as `{ success, error }`).
|
||||
*/
|
||||
constructor (message, code, extra = {}) {
|
||||
super(message);
|
||||
|
||||
// Restore the prototype chain: subclassing the built-in Error loses it
|
||||
// once the bundle is minified/transpiled, which would make both
|
||||
// `instanceof PuterJSError` and `instanceof Error` return false.
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
|
||||
// `name` is defined non-enumerable (like a native Error's) so it stays
|
||||
// out of `JSON.stringify`, and as an own property so it survives
|
||||
// minification of the class name.
|
||||
Object.defineProperty(this, 'name', {
|
||||
value: 'PuterJSError',
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// A native Error's `message` is non-enumerable; redefine it enumerable
|
||||
// so the thrown value serializes and spreads like the legacy plain
|
||||
// `{ message, code }` object it replaces.
|
||||
Object.defineProperty(this, 'message', {
|
||||
value: message,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
if ( code !== undefined ) this.code = code;
|
||||
Object.assign(this, extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an arbitrary thrown value into a `PuterJSError`, passing an
|
||||
* existing one through untouched. Object values keep their `message`,
|
||||
* `code`, and every other own field; primitives become the message.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {PuterJSError}
|
||||
*/
|
||||
static from (value) {
|
||||
if ( value instanceof PuterJSError ) return value;
|
||||
|
||||
if ( value !== null && typeof value === 'object' ) {
|
||||
const { message, code, ...rest } = /** @type {Record<string, unknown>} */ (value);
|
||||
return new PuterJSError(
|
||||
typeof message === 'string' ? message : 'Unknown error',
|
||||
typeof code === 'string' ? code : undefined,
|
||||
rest,
|
||||
);
|
||||
}
|
||||
|
||||
return new PuterJSError(typeof value === 'string' ? value : 'Unknown error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PuterJSError } from './PuterJSError.js';
|
||||
|
||||
describe('PuterJSError', () => {
|
||||
it('is a real Error carrying message and code', () => {
|
||||
const err = new PuterJSError('Key cannot be undefined', 'key_undefined');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(PuterJSError);
|
||||
expect(err.name).toBe('PuterJSError');
|
||||
expect(err.message).toBe('Key cannot be undefined');
|
||||
expect(err.code).toBe('key_undefined');
|
||||
expect(typeof err.stack).toBe('string');
|
||||
});
|
||||
|
||||
it('serializes and destructures like the legacy plain { message, code }', () => {
|
||||
const err = new PuterJSError('bad request', 'invalid_request');
|
||||
const { message, code } = err;
|
||||
expect({ message, code }).toEqual({ message: 'bad request', code: 'invalid_request' });
|
||||
expect(JSON.parse(JSON.stringify(err))).toEqual({
|
||||
message: 'bad request',
|
||||
code: 'invalid_request',
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches extra fields for backward-compatible shapes', () => {
|
||||
const err = new PuterJSError('Name is required', 'invalid_request', {
|
||||
success: false,
|
||||
error: { code: 'invalid_request', message: 'Name is required' },
|
||||
});
|
||||
// Both the new top-level access and the legacy nested access work.
|
||||
expect(err.code).toBe('invalid_request');
|
||||
expect(err.success).toBe(false);
|
||||
expect(err.error).toEqual({ code: 'invalid_request', message: 'Name is required' });
|
||||
});
|
||||
|
||||
it('omits code when none is given', () => {
|
||||
const err = new PuterJSError('something happened');
|
||||
expect('code' in err).toBe(false);
|
||||
expect(JSON.parse(JSON.stringify(err))).toEqual({ message: 'something happened' });
|
||||
});
|
||||
|
||||
describe('from()', () => {
|
||||
it('passes an existing PuterJSError through untouched', () => {
|
||||
const original = new PuterJSError('x', 'y');
|
||||
expect(PuterJSError.from(original)).toBe(original);
|
||||
});
|
||||
|
||||
it('normalizes a plain { message, code } object, keeping extra fields', () => {
|
||||
const err = PuterJSError.from({ message: 'nope', code: 'denied', detail: 42 });
|
||||
expect(err).toBeInstanceOf(PuterJSError);
|
||||
expect(err.message).toBe('nope');
|
||||
expect(err.code).toBe('denied');
|
||||
expect(err.detail).toBe(42);
|
||||
});
|
||||
|
||||
it('wraps a bare string', () => {
|
||||
const err = PuterJSError.from('boom');
|
||||
expect(err.message).toBe('boom');
|
||||
expect('code' in err).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to a generic message for shapeless values', () => {
|
||||
expect(PuterJSError.from(undefined).message).toBe('Unknown error');
|
||||
expect(PuterJSError.from({}).message).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,308 +0,0 @@
|
||||
import * as utils from '../lib/utils.js';
|
||||
import { fetchUrl } from '../lib/networkUtils.js';
|
||||
import { fetchAllPages, iteratePages } from '../lib/pagination.js';
|
||||
|
||||
class Apps {
|
||||
/**
|
||||
* Creates a new instance with the given authentication token, API origin, and app ID,
|
||||
*
|
||||
* @class
|
||||
* @param {string} authToken - Token used to authenticate the user.
|
||||
* @param {string} APIOrigin - Origin of the API server. Used to build the API endpoint URLs.
|
||||
* @param {string} appID - ID of the app to use.
|
||||
*/
|
||||
constructor (puter) {
|
||||
this.puter = puter;
|
||||
this.authToken = puter.authToken;
|
||||
this.APIOrigin = puter.APIOrigin;
|
||||
this.appID = puter.appID;
|
||||
}
|
||||
|
||||
#addUserIterationToApp (app) {
|
||||
app.getUsers = async (params) => {
|
||||
params = params ?? {};
|
||||
return (await puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', { app_uuid: app.uid, limit: params.limit, offset: params.offset })).result;
|
||||
};
|
||||
app.users = async function* (pageSize = 100) {
|
||||
let offset = 0;
|
||||
|
||||
while ( true ) {
|
||||
const users = await app.getUsers({ limit: pageSize, offset });
|
||||
|
||||
if ( !users || users.length === 0 ) return;
|
||||
|
||||
for ( const user of users ) {
|
||||
yield user;
|
||||
}
|
||||
|
||||
offset += users.length;
|
||||
if ( users.length < pageSize ) return;
|
||||
}
|
||||
};
|
||||
return app;
|
||||
}
|
||||
|
||||
#addUserIterationToApps (apps) {
|
||||
apps.forEach(app => {
|
||||
this.#addUserIterationToApp(app);
|
||||
});
|
||||
return apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new authentication token.
|
||||
*
|
||||
* @param {string} authToken - The new authentication token.
|
||||
* @memberof [Apps]
|
||||
* @returns {void}
|
||||
*/
|
||||
setAuthToken (authToken) {
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the API origin.
|
||||
*
|
||||
* @param {string} APIOrigin - The new API origin.
|
||||
* @memberof [Apps]
|
||||
* @returns {void}
|
||||
*/
|
||||
setAPIOrigin (APIOrigin) {
|
||||
this.APIOrigin = APIOrigin;
|
||||
}
|
||||
|
||||
list = (...args) => {
|
||||
// if args is a single object, assume it is the options object.
|
||||
// Pagination keys (and `stream`) are lifted to top-level driver args;
|
||||
// the rest (icon_size, stats_period, ...) stay in `params`.
|
||||
const isObjectForm = typeof args[0] === 'object' && args[0] !== null;
|
||||
const opts = isObjectForm ? args[0] : {};
|
||||
const { limit, offset, cursor, includeTotal, stream, ...params } = opts;
|
||||
const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor');
|
||||
|
||||
const select = utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'select', { readonly: true });
|
||||
const base = { predicate: ['user-can-edit'] };
|
||||
if ( isObjectForm ) base.params = params;
|
||||
if ( limit !== undefined ) base.limit = limit;
|
||||
const fetchPage = pageParams => select.call(this, { ...base, ...pageParams });
|
||||
|
||||
if ( stream === true ) {
|
||||
if ( offset !== undefined ) {
|
||||
throw { message: '`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.', code: 'invalid_request' };
|
||||
}
|
||||
const self = this;
|
||||
return (async function* () {
|
||||
for await ( const page of iteratePages(fetchPage, { cursor, includeTotal: includeTotal === true }) ) {
|
||||
self.#addUserIterationToApps(page.items ?? []);
|
||||
yield page;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Any pagination param keeps the single-request behavior: a bare
|
||||
// (possibly limit-capped) array, or the page envelope once the
|
||||
// request opts into pagination via cursor/offset/includeTotal.
|
||||
if ( limit !== undefined || offset !== undefined || hasCursor || includeTotal !== undefined ) {
|
||||
return (async () => {
|
||||
const options = { ...base };
|
||||
if ( offset !== undefined ) options.offset = offset;
|
||||
if ( hasCursor ) options.cursor = cursor ?? null;
|
||||
if ( includeTotal !== undefined ) options.includeTotal = includeTotal;
|
||||
const result = await select.call(this, options);
|
||||
if ( result && !Array.isArray(result) && Array.isArray(result.items) ) {
|
||||
this.#addUserIterationToApps(result.items);
|
||||
return result;
|
||||
}
|
||||
return this.#addUserIterationToApps(result);
|
||||
})();
|
||||
}
|
||||
|
||||
// Unbound listing: fetch page by page under the hood so no single
|
||||
// request carries the whole result, then return the legacy array.
|
||||
return fetchAllPages(fetchPage).then(items => this.#addUserIterationToApps(items));
|
||||
};
|
||||
|
||||
create = async (...args) => {
|
||||
let options = {};
|
||||
// * allows for: puter.apps.new('example-app') *
|
||||
if ( typeof args[0] === 'string' ) {
|
||||
let indexURL = args[1];
|
||||
let title = args[2] ?? args[0];
|
||||
|
||||
options = {
|
||||
object: {
|
||||
name: args[0],
|
||||
index_url: indexURL,
|
||||
title: title,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// * allows for: puter.apps.new({name: 'example-app', indexURL: 'https://example.com'}) *
|
||||
else if ( typeof args[0] === 'object' && args[0] !== null ) {
|
||||
let options_raw = args[0];
|
||||
|
||||
options = {
|
||||
object: {
|
||||
name: options_raw.name,
|
||||
index_url: options_raw.indexURL,
|
||||
// title is optional only if name is provided.
|
||||
// If title is provided, use it. If not, use name.
|
||||
title: options_raw.title ?? options_raw.name,
|
||||
description: options_raw.description,
|
||||
icon: options_raw.icon,
|
||||
maximize_on_start: options_raw.maximizeOnStart,
|
||||
background: options_raw.background,
|
||||
filetype_associations: options_raw.filetypeAssociations,
|
||||
metadata: options_raw.metadata,
|
||||
},
|
||||
options: {
|
||||
dedupe_name: options_raw.dedupeName ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// name and indexURL are required
|
||||
if ( ! options.object.name ) {
|
||||
throw {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'invalid_request',
|
||||
message: 'Name is required',
|
||||
},
|
||||
};
|
||||
}
|
||||
if ( ! options.object.index_url ) {
|
||||
throw {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'invalid_request',
|
||||
message: 'Index URL is required',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Call the original chat.complete method
|
||||
return this.#addUserIterationToApp(await utils.make_driver_method(['object'], 'puter-apps', 'es:app', 'create').call(this, options));
|
||||
};
|
||||
|
||||
update = async (...args) => {
|
||||
let options = {};
|
||||
|
||||
// if there is one string argument, assume it is the app name
|
||||
// * allows for: puter.apps.update('example-app') *
|
||||
if ( Array.isArray(args) && typeof args[0] === 'string' ) {
|
||||
let object_raw = args[1];
|
||||
let object = {
|
||||
name: object_raw.name,
|
||||
index_url: object_raw.indexURL,
|
||||
title: object_raw.title,
|
||||
description: object_raw.description,
|
||||
icon: object_raw.icon,
|
||||
maximize_on_start: object_raw.maximizeOnStart,
|
||||
background: object_raw.background,
|
||||
filetype_associations: object_raw.filetypeAssociations,
|
||||
metadata: object_raw.metadata,
|
||||
};
|
||||
|
||||
options = { id: { name: args[0] }, object: object };
|
||||
}
|
||||
|
||||
// Call the original chat.complete method
|
||||
return this.#addUserIterationToApp(await utils.make_driver_method(['object'], 'puter-apps', 'es:app', 'update').call(this, options));
|
||||
};
|
||||
|
||||
get = async (...args) => {
|
||||
let options = {};
|
||||
|
||||
// if there is one string argument, assume it is the app name
|
||||
// * allows for: puter.apps.get('example-app') *
|
||||
if ( Array.isArray(args) && typeof args[0] === 'string' ) {
|
||||
// if second argument is an object, assume it is the options object
|
||||
if ( typeof args[1] === 'object' && args[1] !== null ) {
|
||||
options.params = args[1];
|
||||
}
|
||||
// name
|
||||
options.id = { name: args[0] };
|
||||
}
|
||||
|
||||
// if first argument is an object, assume it is the options object
|
||||
if ( typeof args[0] === 'object' && args[0] !== null ) {
|
||||
options.params = args[0];
|
||||
}
|
||||
return this.#addUserIterationToApp(await utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'read', { readonly: true }).call(this, options));
|
||||
};
|
||||
|
||||
delete = async (...args) => {
|
||||
let options = {};
|
||||
// if there is one string argument, assume it is the app name
|
||||
// * allows for: puter.apps.get('example-app') *
|
||||
if ( Array.isArray(args) && typeof args[0] === 'string' ) {
|
||||
options = { id: { name: args[0] } };
|
||||
}
|
||||
return utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'delete').call(this, options);
|
||||
};
|
||||
|
||||
checkName = async (name) => {
|
||||
if ( typeof name !== 'string' || name.length === 0 ) {
|
||||
throw {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'invalid_request',
|
||||
message: 'Name is required',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const resp = await fetchUrl(
|
||||
`${puter.APIOrigin}/apps/nameAvailable?name=${encodeURIComponent(name)}`,
|
||||
{ includePuterAuth: true },
|
||||
);
|
||||
const result = await resp.json();
|
||||
if ( ! resp.ok ) {
|
||||
throw result;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
getDeveloperProfile = function (...args) {
|
||||
let options;
|
||||
|
||||
// If first argument is an object, it's the options
|
||||
if ( typeof args[0] === 'object' && args[0] !== null ) {
|
||||
options = args[0];
|
||||
} else {
|
||||
// Otherwise, we assume separate arguments are provided
|
||||
options = {
|
||||
success: args[0],
|
||||
error: args[1],
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resUpper, rejUpper) => {
|
||||
let options;
|
||||
|
||||
// If first argument is an object, it's the options
|
||||
if ( typeof args[0] === 'object' && args[0] !== null ) {
|
||||
options = args[0];
|
||||
} else {
|
||||
// Otherwise, we assume separate arguments are provided
|
||||
options = {
|
||||
success: args[0],
|
||||
error: args[1],
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = utils.initXhr('/get-dev-profile', puter.APIOrigin, puter.authToken, 'get');
|
||||
|
||||
// set up event handlers for load and error events
|
||||
utils.setupXhrEventHandlers(xhr, options.success ?? resUpper, options.error ?? rejUpper, resolve, reject);
|
||||
|
||||
xhr.send();
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default Apps;
|
||||
@@ -1,194 +0,0 @@
|
||||
import * as utils from '../lib/utils.js';
|
||||
import { fetchAllPages, iteratePages } from '../lib/pagination.js';
|
||||
import getAbsolutePathForApp from './FileSystem/utils/getAbsolutePathForApp.js';
|
||||
|
||||
class Hosting {
|
||||
/**
|
||||
* Creates a new instance with the given authentication token, API origin, and app ID,
|
||||
*
|
||||
* @class
|
||||
* @param {string} authToken - Token used to authenticate the user.
|
||||
* @param {string} APIOrigin - Origin of the API server. Used to build the API endpoint URLs.
|
||||
* @param {string} appID - ID of the app to use.
|
||||
*/
|
||||
constructor (puter) {
|
||||
this.puter = puter;
|
||||
this.authToken = puter.authToken;
|
||||
this.APIOrigin = puter.APIOrigin;
|
||||
this.appID = puter.appID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new authentication token.
|
||||
*
|
||||
* @param {string} authToken - The new authentication token.
|
||||
* @memberof [Router]
|
||||
* @returns {void}
|
||||
*/
|
||||
setAuthToken (authToken) {
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the API origin.
|
||||
*
|
||||
* @param {string} APIOrigin - The new API origin.
|
||||
* @memberof [Apps]
|
||||
* @returns {void}
|
||||
*/
|
||||
setAPIOrigin (APIOrigin) {
|
||||
this.APIOrigin = APIOrigin;
|
||||
}
|
||||
|
||||
// Older backends include worker-backed subdomain rows in select;
|
||||
// current ones exclude them server-side.
|
||||
#withoutWorkerRows (items) {
|
||||
return items.filter(e => !e.subdomain.startsWith('workers.puter.'));
|
||||
}
|
||||
|
||||
// todo document the `Subdomain` object.
|
||||
list = (...args) => {
|
||||
const select = utils.make_driver_method([], 'puter-subdomains', undefined, 'select', { readonly: true });
|
||||
|
||||
const opts = (typeof args[0] === 'object' && args[0] !== null) ? args[0] : {};
|
||||
const { limit, offset, cursor, includeTotal, stream, success, error, ...rest } = opts;
|
||||
const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor');
|
||||
|
||||
const base = { ...rest };
|
||||
if ( limit !== undefined ) base.limit = limit;
|
||||
const fetchPage = pageParams => select({ ...base, ...pageParams });
|
||||
|
||||
if ( stream === true ) {
|
||||
if ( offset !== undefined ) {
|
||||
throw { message: '`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.', code: 'invalid_request' };
|
||||
}
|
||||
const self = this;
|
||||
return (async function* () {
|
||||
for await ( const page of iteratePages(fetchPage, { cursor, includeTotal: includeTotal === true }) ) {
|
||||
yield { ...page, items: self.#withoutWorkerRows(page.items ?? []) };
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Any pagination param keeps the single-request behavior (envelope
|
||||
// once the request opts in via cursor/includeTotal).
|
||||
if ( limit !== undefined || offset !== undefined || hasCursor || includeTotal !== undefined ) {
|
||||
return (async () => {
|
||||
const result = await select(...args);
|
||||
if ( result && !Array.isArray(result) && Array.isArray(result.items) ) {
|
||||
return result;
|
||||
}
|
||||
return this.#withoutWorkerRows(result);
|
||||
})();
|
||||
}
|
||||
|
||||
// Unbound listing: fetch page by page under the hood so no single
|
||||
// request carries the whole result, then return the legacy array.
|
||||
const promise = fetchAllPages(fetchPage).then(items => this.#withoutWorkerRows(items));
|
||||
// Legacy callback forms: list(success, error) and list({ success, error }).
|
||||
// Mirror handle_resp: the callback fires once with the full result and
|
||||
// the returned promise still settles the same way.
|
||||
const success_cb = typeof args[0] === 'function' ? args[0] : success;
|
||||
const error_cb = typeof args[0] === 'function' ? args[1] : error;
|
||||
if ( typeof success_cb === 'function' || typeof error_cb === 'function' ) {
|
||||
promise.then(
|
||||
result => { if ( typeof success_cb === 'function' ) success_cb(result); },
|
||||
err => { if ( typeof error_cb === 'function' ) error_cb(err); },
|
||||
);
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
create = async (...args) => {
|
||||
let options = {};
|
||||
// * allows for: puter.hosting.new('example-subdomain') *
|
||||
if ( typeof args[0] === 'string' && args.length === 1 ) {
|
||||
// if subdomain is in the format of a `subdomain.puter.site` or `subdomain.puter.com`, extract the subdomain
|
||||
// and use it as the subdomain. This is to make development easier.
|
||||
if ( args[0].match(/^[a-z0-9]+\.puter\.(site|com)$/) ) {
|
||||
args[0] = args[0].split('.')[0];
|
||||
}
|
||||
|
||||
options = { object: { subdomain: args[0] } };
|
||||
}
|
||||
// if there are two string arguments, assume they are the subdomain and the target directory
|
||||
// * allows for: puter.hosting.new('example-subdomain', '/path/to/target') *
|
||||
else if ( Array.isArray(args) && args.length === 2 && typeof args[0] === 'string' ) {
|
||||
// if subdomain is in the format of a `subdomain.puter.site` or `subdomain.puter.com`, extract the subdomain
|
||||
// and use it as the subdomain. This is to make development easier.
|
||||
if ( args[0].match(/^[a-z0-9]+\.puter\.(site|com)$/) ) {
|
||||
args[0] = args[0].split('.')[0];
|
||||
}
|
||||
|
||||
// if the target directory is not an absolute path, make it an absolute path relative to the app's root directory
|
||||
if ( args[1] ) {
|
||||
args[1] = getAbsolutePathForApp(args[1]);
|
||||
}
|
||||
|
||||
options = { object: { subdomain: args[0], root_dir: args[1] } };
|
||||
}
|
||||
// allows for: puter.hosting.new({ subdomain: 'subdomain' })
|
||||
else if ( typeof args[0] === 'object' ) {
|
||||
options = { object: args[0] };
|
||||
}
|
||||
// Call the original chat.complete method
|
||||
return await utils.make_driver_method(['object'], 'puter-subdomains', undefined, 'create').call(this, options);
|
||||
};
|
||||
|
||||
update = async (...args) => {
|
||||
let options = {};
|
||||
// If there are two string arguments, assume they are the subdomain and the target directory
|
||||
// * allows for: puter.hosting.update('example-subdomain', '/path/to/target') *
|
||||
if ( Array.isArray(args) && typeof args[0] === 'string' ) {
|
||||
// if subdomain is in the format of a `subdomain.puter.site` or `subdomain.puter.com`, extract the subdomain
|
||||
// and use it as the subdomain. This is to make development easier.
|
||||
if ( args[0].match(/^[a-z0-9]+\.puter\.(site|com)$/) ) {
|
||||
args[0] = args[0].split('.')[0];
|
||||
}
|
||||
|
||||
// if the target directory is not an absolute path, make it an absolute path relative to the app's root directory
|
||||
if ( args[1] ) {
|
||||
args[1] = getAbsolutePathForApp(args[1]);
|
||||
}
|
||||
|
||||
options = { id: { subdomain: args[0] }, object: { root_dir: args[1] ?? null } };
|
||||
}
|
||||
|
||||
// Call the original chat.complete method
|
||||
return await utils.make_driver_method(['object'], 'puter-subdomains', undefined, 'update').call(this, options);
|
||||
};
|
||||
|
||||
get = async (...args) => {
|
||||
let options = {};
|
||||
// if there is one string argument, assume it is the subdomain
|
||||
// * allows for: puter.hosting.get('example-subdomain') *
|
||||
if ( Array.isArray(args) && typeof args[0] === 'string' ) {
|
||||
// if subdomain is in the format of a `subdomain.puter.site` or `subdomain.puter.com`, extract the subdomain
|
||||
// and use it as the subdomain. This is to make development easier.
|
||||
if ( args[0].match(/^[a-z0-9]+\.puter\.(site|com)$/) ) {
|
||||
args[0] = args[0].split('.')[0];
|
||||
}
|
||||
|
||||
options = { id: { subdomain: args[0] } };
|
||||
}
|
||||
return utils.make_driver_method(['uid'], 'puter-subdomains', undefined, 'read', { readonly: true }).call(this, options);
|
||||
};
|
||||
|
||||
delete = async (...args) => {
|
||||
let options = {};
|
||||
// if there is one string argument, assume it is the subdomain
|
||||
// * allows for: puter.hosting.get('example-subdomain') *
|
||||
if ( Array.isArray(args) && typeof args[0] === 'string' ) {
|
||||
// if subdomain is in the format of a `subdomain.puter.site` or `subdomain.puter.com`, extract the subdomain
|
||||
// and use it as the subdomain. This is to make development easier.
|
||||
if ( args[0].match(/^[a-z0-9]+\.puter\.(site|com)$/) ) {
|
||||
args[0] = args[0].split('.')[0];
|
||||
}
|
||||
|
||||
options = { id: { subdomain: args[0] } };
|
||||
}
|
||||
return utils.make_driver_method(['uid'], 'puter-subdomains', undefined, 'delete').call(this, options);
|
||||
};
|
||||
}
|
||||
|
||||
export default Hosting;
|
||||
@@ -1,96 +0,0 @@
|
||||
import * as utils from '../lib/utils.js';
|
||||
|
||||
class OS {
|
||||
/**
|
||||
* Creates a new instance with the given authentication token, API origin, and app ID,
|
||||
*
|
||||
* @class
|
||||
* @param {string} authToken - Token used to authenticate the user.
|
||||
* @param {string} APIOrigin - Origin of the API server. Used to build the API endpoint URLs.
|
||||
* @param {string} appID - ID of the app to use.
|
||||
*/
|
||||
constructor (puter) {
|
||||
this.puter = puter;
|
||||
this.authToken = puter.authToken;
|
||||
this.APIOrigin = puter.APIOrigin;
|
||||
this.appID = puter.appID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new authentication token.
|
||||
*
|
||||
* @param {string} authToken - The new authentication token.
|
||||
* @memberof [OS]
|
||||
* @returns {void}
|
||||
*/
|
||||
setAuthToken (authToken) {
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the API origin.
|
||||
*
|
||||
* @param {string} APIOrigin - The new API origin.
|
||||
* @memberof [Apps]
|
||||
* @returns {void}
|
||||
*/
|
||||
setAPIOrigin (APIOrigin) {
|
||||
this.APIOrigin = APIOrigin;
|
||||
}
|
||||
|
||||
user = function (...args) {
|
||||
let options;
|
||||
|
||||
// If first argument is an object, it's the options
|
||||
if ( typeof args[0] === 'object' && args[0] !== null ) {
|
||||
options = args[0];
|
||||
} else {
|
||||
// Otherwise, we assume separate arguments are provided
|
||||
options = {
|
||||
success: args[0],
|
||||
error: args[1],
|
||||
};
|
||||
}
|
||||
|
||||
let query = '';
|
||||
if ( options?.query ) {
|
||||
query = `?${ new URLSearchParams(options.query).toString()}`;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = utils.initXhr(`/whoami${ query}`, this.APIOrigin, this.authToken, 'get');
|
||||
|
||||
// set up event handlers for load and error events
|
||||
utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject);
|
||||
|
||||
xhr.send();
|
||||
});
|
||||
};
|
||||
|
||||
version = function (...args) {
|
||||
let options;
|
||||
|
||||
// If first argument is an object, it's the options
|
||||
if ( typeof args[0] === 'object' && args[0] !== null ) {
|
||||
options = args[0];
|
||||
} else {
|
||||
// Otherwise, we assume separate arguments are provided
|
||||
options = {
|
||||
success: args[0],
|
||||
error: args[1],
|
||||
// Add more if needed...
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = utils.initXhr('/version', this.APIOrigin, this.authToken, 'get');
|
||||
|
||||
// set up event handlers for load and error events
|
||||
utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject);
|
||||
|
||||
xhr.send();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default OS;
|
||||
@@ -1,430 +0,0 @@
|
||||
import { fetchUrl } from '../lib/networkUtils.js';
|
||||
|
||||
export default class Perms {
|
||||
constructor (puter) {
|
||||
this.puter = puter;
|
||||
this.authToken = puter.authToken;
|
||||
this.APIOrigin = puter.APIOrigin;
|
||||
}
|
||||
setAuthToken (authToken) {
|
||||
this.authToken = authToken;
|
||||
}
|
||||
setAPIOrigin (APIOrigin) {
|
||||
this.APIOrigin = APIOrigin;
|
||||
}
|
||||
async req_ (route, body) {
|
||||
try {
|
||||
const resp = await fetchUrl(this.APIOrigin + route, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
includePuterAuth: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
if ( resp.headers.get('content-type')?.includes('application/json') ) {
|
||||
const jsonResult = await resp.json();
|
||||
if ( resp.status !== 200 ) {
|
||||
jsonResult.error = true;
|
||||
}
|
||||
return jsonResult;
|
||||
}
|
||||
return { error: true, message: await resp.text(), code: 'unknown_error' };
|
||||
} catch (e) {
|
||||
return { error: true, message: e.message, code: 'internal_error' };
|
||||
}
|
||||
}
|
||||
|
||||
// Grant Permissions
|
||||
async grantUser (target_username, permission) {
|
||||
return await this.req_('/auth/grant-user-user', {
|
||||
target_username, permission,
|
||||
});
|
||||
}
|
||||
|
||||
async grantGroup (group_uid, permission) {
|
||||
return await this.req_('/auth/grant-user-group', {
|
||||
group_uid, permission,
|
||||
});
|
||||
}
|
||||
|
||||
async grantApp (app_uid, permission) {
|
||||
return await this.req_('/auth/grant-user-app', {
|
||||
app_uid, permission,
|
||||
});
|
||||
}
|
||||
|
||||
async grantAppAnyUser (app_uid, permission) {
|
||||
return await this.req_('/auth/grant-dev-app', {
|
||||
app_uid, permission,
|
||||
});
|
||||
}
|
||||
|
||||
async grantOrigin (origin, permission) {
|
||||
return await this.req_('/auth/grant-user-app', {
|
||||
origin, permission,
|
||||
});
|
||||
}
|
||||
|
||||
// Revoke Permissions
|
||||
async revokeUser (target_username, permission) {
|
||||
return await this.req_('/auth/revoke-user-user', {
|
||||
target_username, permission,
|
||||
});
|
||||
}
|
||||
|
||||
async revokeGroup (group_uid, permission) {
|
||||
return await this.req_('/auth/revoke-user-group', {
|
||||
group_uid, permission,
|
||||
});
|
||||
}
|
||||
|
||||
async revokeApp (app_uid, permission) {
|
||||
return await this.req_('/auth/revoke-user-app', {
|
||||
app_uid, permission,
|
||||
});
|
||||
}
|
||||
|
||||
async revokeAppAnyUser (app_uid, permission) {
|
||||
return await this.req_('/auth/revoke-dev-app', {
|
||||
app_uid, permission,
|
||||
});
|
||||
}
|
||||
|
||||
async revokeOrigin (origin, permission) {
|
||||
return await this.req_('/auth/revoke-user-app', {
|
||||
origin, permission,
|
||||
});
|
||||
}
|
||||
|
||||
// Group Management
|
||||
async createGroup (metadata = {}, extra = {}) {
|
||||
return await this.req_('/group/create', {
|
||||
metadata, extra,
|
||||
});
|
||||
}
|
||||
async addUsersToGroup (uid, usernames) {
|
||||
return await this.req_('/group/add-users', {
|
||||
uid,
|
||||
users: usernames ?? [],
|
||||
});
|
||||
}
|
||||
async removeUsersFromGroup (uid, usernames) {
|
||||
return await this.req_('/group/remove-users', {
|
||||
uid,
|
||||
users: usernames ?? [],
|
||||
});
|
||||
}
|
||||
async listGroups () {
|
||||
return await this.req_('/group/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use .request() instead
|
||||
*/
|
||||
requestPermission (...a) {
|
||||
return this.request(...a);
|
||||
};
|
||||
|
||||
/**
|
||||
* Request a specific permission string to be granted. Note that some
|
||||
* permission strings are not supported and will be denied silently.
|
||||
* @param {string} permission - permission string to request
|
||||
* @returns {boolean} true if permission was granted, false otherwise
|
||||
*/
|
||||
async request (permission) {
|
||||
// note: we cannot move this fully from "puter.ui" without
|
||||
// a significant refactor because the UI module contains
|
||||
// all of the IPC communication logic.
|
||||
return await this.puter.ui.requestPermission({ permission });
|
||||
};
|
||||
|
||||
// #region shorthand functions
|
||||
/**
|
||||
* Request to see a user's email. If the user has already granted this
|
||||
* permission the user will not be prompted and their email address
|
||||
* will be returned. If the user grants permission their email address will
|
||||
* be returned. If the user does not allow access `undefined` will be
|
||||
* returned. If the user does not have an email address, the value of their
|
||||
* email address will be `null`.
|
||||
*
|
||||
* @return {string|null|undefined} An email address or undefined
|
||||
*/
|
||||
async requestEmail () {
|
||||
let whoami;
|
||||
whoami = await this.puter.auth.whoami();
|
||||
if ( whoami.email !== undefined ) return whoami.email;
|
||||
const granted = await this.puter.ui.requestPermission({
|
||||
permission: `user:${whoami.uuid}:email:read`,
|
||||
});
|
||||
if ( granted ) {
|
||||
whoami = await this.puter.auth.whoami();
|
||||
}
|
||||
return whoami.email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request read access to the user's Desktop folder. If the user has already
|
||||
* granted this permission the user will not be prompted and the path will
|
||||
* be returned. If the user grants permission the path will be returned.
|
||||
* If the user does not allow access `undefined` will be returned.
|
||||
*
|
||||
* @return {string|undefined} The Desktop path or undefined
|
||||
*/
|
||||
async requestReadDesktop () {
|
||||
return this.requestFolder_('Desktop', 'read');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request write access to the user's Desktop folder. If the user has already
|
||||
* granted this permission the user will not be prompted and the path will
|
||||
* be returned. If the user grants permission the path will be returned.
|
||||
* If the user does not allow access `undefined` will be returned.
|
||||
*
|
||||
* @return {string|undefined} The Desktop path or undefined
|
||||
*/
|
||||
async requestWriteDesktop () {
|
||||
return this.requestFolder_('Desktop', 'write');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request read access to the user's Documents folder. If the user has already
|
||||
* granted this permission the user will not be prompted and the path will
|
||||
* be returned. If the user grants permission the path will be returned.
|
||||
* If the user does not allow access `undefined` will be returned.
|
||||
*
|
||||
* @return {string|undefined} The Documents path or undefined
|
||||
*/
|
||||
async requestReadDocuments () {
|
||||
return this.requestFolder_('Documents', 'read');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request write access to the user's Documents folder. If the user has already
|
||||
* granted this permission the user will not be prompted and the path will
|
||||
* be returned. If the user grants permission the path will be returned.
|
||||
* If the user does not allow access `undefined` will be returned.
|
||||
*
|
||||
* @return {string|undefined} The Documents path or undefined
|
||||
*/
|
||||
async requestWriteDocuments () {
|
||||
return this.requestFolder_('Documents', 'write');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request read access to the user's Pictures folder. If the user has already
|
||||
* granted this permission the user will not be prompted and the path will
|
||||
* be returned. If the user grants permission the path will be returned.
|
||||
* If the user does not allow access `undefined` will be returned.
|
||||
*
|
||||
* @return {string|undefined} The Pictures path or undefined
|
||||
*/
|
||||
async requestReadPictures () {
|
||||
return this.requestFolder_('Pictures', 'read');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request write access to the user's Pictures folder. If the user has already
|
||||
* granted this permission the user will not be prompted and the path will
|
||||
* be returned. If the user grants permission the path will be returned.
|
||||
* If the user does not allow access `undefined` will be returned.
|
||||
*
|
||||
* @return {string|undefined} The Pictures path or undefined
|
||||
*/
|
||||
async requestWritePictures () {
|
||||
return this.requestFolder_('Pictures', 'write');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request read access to the user's Videos folder. If the user has already
|
||||
* granted this permission the user will not be prompted and the path will
|
||||
* be returned. If the user grants permission the path will be returned.
|
||||
* If the user does not allow access `undefined` will be returned.
|
||||
*
|
||||
* @return {string|undefined} The Videos path or undefined
|
||||
*/
|
||||
async requestReadVideos () {
|
||||
return this.requestFolder_('Videos', 'read');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request write access to the user's Videos folder. If the user has already
|
||||
* granted this permission the user will not be prompted and the path will
|
||||
* be returned. If the user grants permission the path will be returned.
|
||||
* If the user does not allow access `undefined` will be returned.
|
||||
*
|
||||
* @return {string|undefined} The Videos path or undefined
|
||||
*/
|
||||
async requestWriteVideos () {
|
||||
return this.requestFolder_('Videos', 'write');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request read access to the user's apps. If the user has already granted
|
||||
* this permission the user will not be prompted and `true` will be returned.
|
||||
* If the user grants permission `true` will be returned. If the user does
|
||||
* not allow access `false` will be returned.
|
||||
*
|
||||
* @return {boolean} Whether read access to apps was granted
|
||||
*/
|
||||
async requestReadApps () {
|
||||
const whoami = await this.puter.auth.whoami();
|
||||
const granted = await this.puter.ui.requestPermission({
|
||||
permission: `apps-of-user:${whoami.uuid}:read`,
|
||||
});
|
||||
return granted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request write (manage) access to the user's apps. If the user has already
|
||||
* granted this permission the user will not be prompted and `true` will be
|
||||
* returned. If the user grants permission `true` will be returned. If the
|
||||
* user does not allow access `false` will be returned.
|
||||
*
|
||||
* @return {boolean} Whether manage access to apps was granted
|
||||
*/
|
||||
async requestManageApps () {
|
||||
const whoami = await this.puter.auth.whoami();
|
||||
const granted = await this.puter.ui.requestPermission({
|
||||
permission: `apps-of-user:${whoami.uuid}:write`,
|
||||
});
|
||||
return granted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request read access to the user's subdomains. If the user has already
|
||||
* granted this permission the user will not be prompted and `true` will be
|
||||
* returned. If the user grants permission `true` will be returned. If the
|
||||
* user does not allow access `false` will be returned.
|
||||
*
|
||||
* @return {boolean} Whether read access to subdomains was granted
|
||||
*/
|
||||
async requestReadSubdomains () {
|
||||
const whoami = await this.puter.auth.whoami();
|
||||
const granted = await this.puter.ui.requestPermission({
|
||||
permission: `subdomains-of-user:${whoami.uuid}:read`,
|
||||
});
|
||||
return granted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request write (manage) access to the user's subdomains. If the user has
|
||||
* already granted this permission the user will not be prompted and `true`
|
||||
* will be returned. If the user grants permission `true` will be returned.
|
||||
* If the user does not allow access `false` will be returned.
|
||||
*
|
||||
* @return {boolean} Whether manage access to subdomains was granted
|
||||
*/
|
||||
async requestManageSubdomains () {
|
||||
const whoami = await this.puter.auth.whoami();
|
||||
const granted = await this.puter.ui.requestPermission({
|
||||
permission: `subdomains-of-user:${whoami.uuid}:write`,
|
||||
});
|
||||
return granted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request read access to the root directory of one of the user's apps,
|
||||
* identified by its resource request code (e.g. from app.resource_request_code).
|
||||
* If the user grants permission, returns the filesystem item for that directory.
|
||||
* If the user denies or an error occurs, returns undefined.
|
||||
*
|
||||
* @param {string} resourceRequestCode - The resource request code (e.g. `${app.uid}:root_dir`)
|
||||
* @return {Promise<object|undefined>} The directory fs item (stat) or undefined
|
||||
*/
|
||||
async requestReadAppRootDir (app_uid) {
|
||||
return await this.#requestAppRootDir('read', app_uid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request write access to the root directory of one of the user's apps,
|
||||
* identified by its resource request code (e.g. from app.resource_request_code).
|
||||
* If the user grants permission, returns the filesystem item for that directory.
|
||||
* If the user denies or an error occurs, returns undefined.
|
||||
*
|
||||
* @param {string} resourceRequestCode - The resource request code (e.g. `${app.uid}:root_dir`)
|
||||
* @return {Promise<object|undefined>} The directory fs item (stat) or undefined
|
||||
*/
|
||||
async requestWriteAppRootDir (app_uid) {
|
||||
return await this.#requestAppRootDir('write', app_uid);
|
||||
}
|
||||
|
||||
async #requestAppRootDir (access, app_uid) {
|
||||
if ( typeof app_uid === 'object' && app_uid !== null ) {
|
||||
app_uid = app_uid.uid;
|
||||
}
|
||||
if ( typeof app_uid !== 'string' ) {
|
||||
throw new Error('parameter app_uid must be a strinkg');
|
||||
}
|
||||
|
||||
let result;
|
||||
const fetchIt = async () => result = await this.req_('/auth/request-app-root-dir', {
|
||||
app_uid,
|
||||
access: 'read',
|
||||
});
|
||||
await fetchIt();
|
||||
if ( ! result.error ) return result;
|
||||
|
||||
// Request permission
|
||||
app_uid = (typeof app_or_uuid === 'object') && app_uid !== null
|
||||
? app_uid.uid : app_uid;
|
||||
const readAppRootDirPermission = `app-root-dir:${app_uid}:read`;
|
||||
const granted = await this.puter.ui.requestPermission({
|
||||
permission: readAppRootDirPermission,
|
||||
});
|
||||
|
||||
if ( granted ) {
|
||||
await fetchIt();
|
||||
|
||||
// If the server has cache invalidation issues, we still want this
|
||||
// to work anyway. This is a hack, but also a reasonable safeguard.
|
||||
let delay = 100;
|
||||
const maxTotalWait = 5000;
|
||||
let totalWaited = 0;
|
||||
while ( result.error && totalWaited < maxTotalWait ) {
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
totalWaited += delay;
|
||||
await fetchIt();
|
||||
if ( ! result.error ) break;
|
||||
delay = Math.min(delay * 2, Math.max(100, maxTotalWait - totalWaited));
|
||||
}
|
||||
}
|
||||
if ( ! result.error ) return result;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to request access to a user's special folder.
|
||||
* @private
|
||||
* @param {string} folderName - The name of the folder (Desktop, Documents, Pictures, Videos)
|
||||
* @param {string} accessLevel - The access level (read, write)
|
||||
* @return {string|undefined} The folder path or undefined
|
||||
*/
|
||||
async requestFolder_ (folderName, accessLevel) {
|
||||
const whoami = await this.puter.auth.whoami();
|
||||
const folderPath = `/${whoami.username}/${folderName}`;
|
||||
|
||||
// Check if we already have access by trying to stat the folder
|
||||
try {
|
||||
await this.puter.fs.stat({ path: folderPath });
|
||||
|
||||
// If we can stat the folder, we have at least read access
|
||||
if ( accessLevel !== 'write' ) {
|
||||
return folderPath;
|
||||
}
|
||||
} catch (e) {
|
||||
// No access yet, need to request permission
|
||||
}
|
||||
|
||||
const granted = await this.puter.ui.requestPermission({
|
||||
permission: `fs:${folderPath}:${accessLevel}`,
|
||||
});
|
||||
|
||||
if ( granted ) {
|
||||
return folderPath;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
// #endregion
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { fetchUrl } from '../../lib/networkUtils.js';
|
||||
import { invalidRequest } from './lib/validate.js';
|
||||
|
||||
/** @typedef {import('../../../types/modules/apps').CheckAppNameResult} CheckAppNameResult */
|
||||
|
||||
/**
|
||||
* Checks whether an app name is available to the user.
|
||||
*
|
||||
* @this {import('./index.js').AppsModule}
|
||||
* @param {string} name
|
||||
* @returns {Promise<CheckAppNameResult>}
|
||||
*/
|
||||
export async function checkName (name) {
|
||||
const { puter } = this;
|
||||
|
||||
if ( typeof name !== 'string' || name.length === 0 ) {
|
||||
throw invalidRequest('Name is required');
|
||||
}
|
||||
|
||||
const resp = await fetchUrl(
|
||||
`${puter.APIOrigin}/apps/nameAvailable?name=${encodeURIComponent(name)}`,
|
||||
{ includePuterAuth: true },
|
||||
);
|
||||
const result = await resp.json();
|
||||
if ( ! resp.ok ) throw result;
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import { addUserIteration } from './lib/appUsers.js';
|
||||
import { toAppObject } from './lib/appObject.js';
|
||||
import { invalidRequest } from './lib/validate.js';
|
||||
|
||||
/** @typedef {import('../../../types/modules/apps').CreateAppOptions} CreateAppOptions */
|
||||
/** @typedef {import('../../../types/modules/apps').CreateAppResult} CreateAppResult */
|
||||
|
||||
/**
|
||||
* @overload
|
||||
* @param {string} name
|
||||
* @param {string} indexURL
|
||||
* @param {string} [title]
|
||||
* @returns {Promise<CreateAppResult>}
|
||||
*/
|
||||
/**
|
||||
* @overload
|
||||
* @param {CreateAppOptions} options
|
||||
* @returns {Promise<CreateAppResult>}
|
||||
*/
|
||||
/**
|
||||
* Creates a Puter app. The name must be unique to the user's apps (rejects
|
||||
* otherwise) and `indexURL` must start with `http://` or `https://`. Accepts
|
||||
* either positional `create(name, indexURL, title?)` or an options object.
|
||||
*
|
||||
* @this {import('./index.js').AppsModule}
|
||||
* @param {string | CreateAppOptions} nameOrOptions
|
||||
* @param {string} [indexURL]
|
||||
* @param {string} [title]
|
||||
* @returns {Promise<CreateAppResult>}
|
||||
*/
|
||||
export async function create (nameOrOptions, indexURL, title) {
|
||||
const { puter } = this;
|
||||
|
||||
let options;
|
||||
if ( typeof nameOrOptions === 'string' ) {
|
||||
options = {
|
||||
object: {
|
||||
name: nameOrOptions,
|
||||
index_url: indexURL,
|
||||
// title is optional; fall back to the name when omitted.
|
||||
title: title ?? nameOrOptions,
|
||||
},
|
||||
};
|
||||
} else if ( typeof nameOrOptions === 'object' && nameOrOptions !== null ) {
|
||||
options = {
|
||||
object: { ...toAppObject(nameOrOptions), title: nameOrOptions.title ?? nameOrOptions.name },
|
||||
options: { dedupe_name: nameOrOptions.dedupeName ?? false },
|
||||
};
|
||||
} else {
|
||||
options = { object: {} };
|
||||
}
|
||||
|
||||
// name and indexURL are required
|
||||
if ( ! options.object.name ) throw invalidRequest('Name is required');
|
||||
if ( ! options.object.index_url ) throw invalidRequest('Index URL is required');
|
||||
|
||||
const created = await utils.make_driver_method(['object'], 'puter-apps', 'es:app', 'create', { puter })(options);
|
||||
return addUserIteration(puter, created);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
|
||||
/**
|
||||
* Deletes the app with the given name. Resolves to `{ success: true, uid }`
|
||||
* with the `uid` of the deleted app.
|
||||
*
|
||||
* @this {import('./index.js').AppsModule}
|
||||
* @param {string} name
|
||||
* @returns {Promise<{ success: boolean, uid: string }>}
|
||||
*/
|
||||
export async function del (name) {
|
||||
const { puter } = this;
|
||||
|
||||
const options = typeof name === 'string' ? { id: { name } } : {};
|
||||
return await utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'delete', { puter })(options);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import { addUserIteration } from './lib/appUsers.js';
|
||||
|
||||
/** @typedef {import('../../../types/modules/apps').App} App */
|
||||
/** @typedef {import('../../../types/modules/apps').AppListOptions} AppListOptions */
|
||||
|
||||
/**
|
||||
* Returns the app with the given name. Rejects if the app does not exist.
|
||||
* The options object (`stats_period`, `icon_size`) may be passed as the second
|
||||
* argument, or as the sole argument.
|
||||
*
|
||||
* @this {import('./index.js').AppsModule}
|
||||
* @param {string | AppListOptions} nameOrOptions
|
||||
* @param {AppListOptions} [options]
|
||||
* @returns {Promise<App>}
|
||||
*/
|
||||
export async function get (nameOrOptions, options) {
|
||||
const { puter } = this;
|
||||
|
||||
const driverArgs = {};
|
||||
if ( typeof nameOrOptions === 'string' ) {
|
||||
if ( typeof options === 'object' && options !== null ) driverArgs.params = options;
|
||||
driverArgs.id = { name: nameOrOptions };
|
||||
}
|
||||
if ( typeof nameOrOptions === 'object' && nameOrOptions !== null ) {
|
||||
driverArgs.params = nameOrOptions;
|
||||
}
|
||||
|
||||
const app = await utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'read', { puter, readonly: true })(driverArgs);
|
||||
return addUserIteration(puter, app);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
|
||||
/**
|
||||
* Fetches the caller's developer profile. Accepts either an options object
|
||||
* (`{ success, error }`) or trailing positional `success`/`error` callbacks;
|
||||
* either way the returned promise settles with the profile.
|
||||
*
|
||||
* @this {import('./index.js').AppsModule}
|
||||
* @param {...(((value: Record<string, unknown>) => void) | { success?: Function, error?: Function })} args
|
||||
* @returns {Promise<Record<string, unknown>>}
|
||||
*/
|
||||
export function getDeveloperProfile (...args) {
|
||||
const { puter } = this;
|
||||
|
||||
const options = (typeof args[0] === 'object' && args[0] !== null)
|
||||
? args[0]
|
||||
: { success: args[0], error: args[1] };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = utils.initXhr('/get-dev-profile', puter.APIOrigin, puter.authToken, 'get');
|
||||
utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject);
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { checkName } from './checkName.js';
|
||||
import { create } from './create.js';
|
||||
import { del } from './delete.js';
|
||||
import { get } from './get.js';
|
||||
import { getDeveloperProfile } from './getDeveloperProfile.js';
|
||||
import { list } from './list.js';
|
||||
import { update } from './update.js';
|
||||
|
||||
/** @typedef {import('../../../types/puter').Puter} Puter */
|
||||
|
||||
/**
|
||||
* The `puter.apps` module. Holds a reference to the owning Puter instance and
|
||||
* reads auth state from it live — nothing is copied out, so token and origin
|
||||
* changes on the instance apply to in-flight modules immediately.
|
||||
*
|
||||
* Method implementations live in the sibling files as `this`-context
|
||||
* functions whose JSDoc (including the per-form `@overload` declarations) is
|
||||
* the source of truth for the public signatures; types/modules/apps.d.ts
|
||||
* mirrors them for TypeScript consumers of the published SDK.
|
||||
*/
|
||||
export class AppsModule {
|
||||
/** @type {Puter} */
|
||||
puter;
|
||||
|
||||
// The fields hold the unbound functions so they keep the full overloaded
|
||||
// types (`bind` erases overloads); the constructor rebinds them at runtime
|
||||
// so destructured calls (`const { create } = puter.apps`) keep working.
|
||||
list = list;
|
||||
create = create;
|
||||
update = update;
|
||||
get = get;
|
||||
delete = del;
|
||||
checkName = checkName;
|
||||
getDeveloperProfile = getDeveloperProfile;
|
||||
|
||||
/** @param {Puter} puter */
|
||||
constructor (puter) {
|
||||
this.puter = puter;
|
||||
|
||||
const methods = /** @type {Record<string, (...args: unknown[]) => unknown>} */ (
|
||||
/** @type {unknown} */ (this)
|
||||
);
|
||||
for ( const name of ['list', 'create', 'update', 'get', 'delete', 'checkName', 'getDeveloperProfile'] ) {
|
||||
methods[name] = methods[name].bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
// Kept for backward compatibility: these used to be copied fields kept in
|
||||
// sync by set{AuthToken,APIOrigin}; they now read through live.
|
||||
get authToken () {
|
||||
return this.puter.authToken;
|
||||
}
|
||||
|
||||
get APIOrigin () {
|
||||
return this.puter.APIOrigin;
|
||||
}
|
||||
|
||||
get appID () {
|
||||
return this.puter.appID;
|
||||
}
|
||||
|
||||
// No-ops: auth state is read from the Puter instance at call time. The
|
||||
// module registry still invokes these on token/origin changes.
|
||||
setAuthToken () {}
|
||||
|
||||
setAPIOrigin () {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public face of the module: derived from the class, with the internal
|
||||
* `puter` handle and the legacy `authToken` accessor omitted.
|
||||
*
|
||||
* @typedef {import('../../lib/types.js').OmitMembers<
|
||||
* typeof AppsModule,
|
||||
* 'puter' | 'authToken'
|
||||
* >} AppsConstructor
|
||||
*/
|
||||
|
||||
export const Apps = /** @type {AppsConstructor} */ (AppsModule);
|
||||
|
||||
export default Apps;
|
||||
@@ -0,0 +1,29 @@
|
||||
// Shared field remapping for the puter.apps methods: the public options use
|
||||
// camelCase (indexURL, maximizeOnStart, ...) while the `puter-apps` driver
|
||||
// expects snake_case. create() and update() share this so the field list
|
||||
// lives in exactly one place.
|
||||
|
||||
/**
|
||||
* @typedef {import('../../../../types/modules/apps').CreateAppOptions
|
||||
* | import('../../../../types/modules/apps').UpdateAppAttributes} AppAttributes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Maps the camelCase public app attributes to the snake_case `object` the
|
||||
* driver stores. `title` is passed through as-is; create() overlays its
|
||||
* name-fallback default on top.
|
||||
*
|
||||
* @param {AppAttributes} raw
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export const toAppObject = (raw) => ({
|
||||
name: raw.name,
|
||||
index_url: raw.indexURL,
|
||||
title: raw.title,
|
||||
description: raw.description,
|
||||
icon: raw.icon,
|
||||
maximize_on_start: raw.maximizeOnStart,
|
||||
background: raw.background,
|
||||
filetype_associations: raw.filetypeAssociations,
|
||||
metadata: raw.metadata,
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Augments returned `App` objects with the `getUsers()` page fetcher and the
|
||||
// `users()` async iterator, backed by the app-telemetry driver. Shared by
|
||||
// every method that returns apps (create/update/get/list).
|
||||
|
||||
/** @typedef {import('../../../../types/puter').Puter} Puter */
|
||||
/** @typedef {import('../../../../types/modules/apps').App} App */
|
||||
|
||||
/**
|
||||
* @param {Puter} puter
|
||||
* @param {App} app
|
||||
* @returns {App}
|
||||
*/
|
||||
export const addUserIteration = (puter, app) => {
|
||||
app.getUsers = async (params) => {
|
||||
params = params ?? {};
|
||||
return (await puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', {
|
||||
app_uuid: app.uid,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
})).result;
|
||||
};
|
||||
|
||||
app.users = async function* (pageSize = 100) {
|
||||
let offset = 0;
|
||||
while ( true ) {
|
||||
const users = await app.getUsers({ limit: pageSize, offset });
|
||||
if ( !users || users.length === 0 ) return;
|
||||
for ( const user of users ) yield user;
|
||||
offset += users.length;
|
||||
if ( users.length < pageSize ) return;
|
||||
}
|
||||
};
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Puter} puter
|
||||
* @param {App[]} apps
|
||||
* @returns {App[]}
|
||||
*/
|
||||
export const addUserIterationToApps = (puter, apps) => {
|
||||
apps.forEach(app => addUserIteration(puter, app));
|
||||
return apps;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PuterJSError } from '../../../lib/PuterJSError.js';
|
||||
|
||||
// puter.apps has always thrown a nested `{ success: false, error: { code,
|
||||
// message } }` for client-side validation failures. PuterJSError keeps that
|
||||
// exact shape (via the extra fields) while also exposing top-level
|
||||
// `message`/`code`, so both `err.error.code` and `err.code` keep working.
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @returns {PuterJSError}
|
||||
*/
|
||||
export const invalidRequest = (message) =>
|
||||
new PuterJSError(message, 'invalid_request', {
|
||||
success: false,
|
||||
error: { code: 'invalid_request', message },
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import { fetchAllPages, iteratePages } from '../../lib/pagination.js';
|
||||
import { PuterJSError } from '../../lib/PuterJSError.js';
|
||||
import { addUserIterationToApps } from './lib/appUsers.js';
|
||||
|
||||
/** @typedef {import('../../../types/modules/apps').App} App */
|
||||
/** @typedef {import('../../../types/modules/apps').AppListOptions} AppListOptions */
|
||||
|
||||
/**
|
||||
* @overload
|
||||
* @param {AppListOptions & import('../../../types/shared').ListStreamOptions} options
|
||||
* @returns {AsyncIterableIterator<import('../../../types/shared').ListPage<App>>}
|
||||
*/
|
||||
/**
|
||||
* @overload
|
||||
* @param {AppListOptions & import('../../../types/shared').ListPaginationOptions & ({ cursor: string | null } | { offset: number } | { includeTotal: true })} options
|
||||
* @returns {Promise<import('../../../types/shared').ListPage<App>>}
|
||||
*/
|
||||
/**
|
||||
* @overload
|
||||
* @param {AppListOptions & { limit?: number }} [options]
|
||||
* @returns {Promise<App[]>}
|
||||
*/
|
||||
/**
|
||||
* Returns the apps the caller can access, fetching page by page under the hood
|
||||
* and resolving to a plain array. Non-pagination options (`stats_period`,
|
||||
* `icon_size`) are forwarded as `params`. Any pagination param
|
||||
* (`cursor`/`offset`/`includeTotal`) switches to a single-request page
|
||||
* envelope, and `stream: true` returns an async iterator of page envelopes.
|
||||
*
|
||||
* @this {import('./index.js').AppsModule}
|
||||
* @param {AppListOptions & (import('../../../types/shared').ListPaginationOptions | import('../../../types/shared').ListStreamOptions)} [options]
|
||||
* @returns {Promise<App[]> | Promise<import('../../../types/shared').ListPage<App>> | AsyncIterableIterator<import('../../../types/shared').ListPage<App>>}
|
||||
*/
|
||||
export function list (options) {
|
||||
const { puter } = this;
|
||||
|
||||
const isObjectForm = typeof options === 'object' && options !== null;
|
||||
const opts = isObjectForm ? options : {};
|
||||
const { limit, offset, cursor, includeTotal, stream, ...params } = opts;
|
||||
const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor');
|
||||
|
||||
const select = utils.make_driver_method(['uid'], 'puter-apps', 'es:app', 'select', { puter, readonly: true });
|
||||
const base = { predicate: ['user-can-edit'] };
|
||||
if ( isObjectForm ) base.params = params;
|
||||
if ( limit !== undefined ) base.limit = limit;
|
||||
const fetchPage = pageParams => select({ ...base, ...pageParams });
|
||||
|
||||
if ( stream === true ) {
|
||||
if ( offset !== undefined ) {
|
||||
throw new PuterJSError(
|
||||
'`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.',
|
||||
'invalid_request',
|
||||
);
|
||||
}
|
||||
return (async function* () {
|
||||
for await ( const page of iteratePages(fetchPage, { cursor, includeTotal: includeTotal === true }) ) {
|
||||
addUserIterationToApps(puter, page.items ?? []);
|
||||
yield page;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Any pagination param keeps the single-request behavior: a bare (possibly
|
||||
// limit-capped) array, or the page envelope once the request opts into
|
||||
// pagination via cursor/offset/includeTotal.
|
||||
if ( limit !== undefined || offset !== undefined || hasCursor || includeTotal !== undefined ) {
|
||||
return (async () => {
|
||||
const driverArgs = { ...base };
|
||||
if ( offset !== undefined ) driverArgs.offset = offset;
|
||||
if ( hasCursor ) driverArgs.cursor = cursor ?? null;
|
||||
if ( includeTotal !== undefined ) driverArgs.includeTotal = includeTotal;
|
||||
const result = await select(driverArgs);
|
||||
if ( result && !Array.isArray(result) && Array.isArray(result.items) ) {
|
||||
addUserIterationToApps(puter, result.items);
|
||||
return result;
|
||||
}
|
||||
return addUserIterationToApps(puter, result);
|
||||
})();
|
||||
}
|
||||
|
||||
// Unbound listing: fetch page by page under the hood so no single request
|
||||
// carries the whole result, then return the legacy array.
|
||||
return fetchAllPages(fetchPage).then(items => addUserIterationToApps(puter, items));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import { addUserIteration } from './lib/appUsers.js';
|
||||
import { toAppObject } from './lib/appObject.js';
|
||||
|
||||
/** @typedef {import('../../../types/modules/apps').App} App */
|
||||
/** @typedef {import('../../../types/modules/apps').UpdateAppAttributes} UpdateAppAttributes */
|
||||
|
||||
/**
|
||||
* Updates attributes of the app with the given name.
|
||||
*
|
||||
* @this {import('./index.js').AppsModule}
|
||||
* @param {string} name
|
||||
* @param {UpdateAppAttributes} attributes
|
||||
* @returns {Promise<App>}
|
||||
*/
|
||||
export async function update (name, attributes) {
|
||||
const { puter } = this;
|
||||
|
||||
let options = {};
|
||||
if ( typeof name === 'string' ) {
|
||||
options = { id: { name }, object: toAppObject(attributes ?? {}) };
|
||||
}
|
||||
|
||||
const updated = await utils.make_driver_method(['object'], 'puter-apps', 'es:app', 'update', { puter })(options);
|
||||
return addUserIteration(puter, updated);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import getAbsolutePathForApp from '../FileSystem/utils/getAbsolutePathForApp.js';
|
||||
import { normalizeSubdomain } from './lib/args.js';
|
||||
|
||||
/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */
|
||||
|
||||
/**
|
||||
* @overload
|
||||
* @param {string} subdomain
|
||||
* @param {string} dirPath
|
||||
* @returns {Promise<Subdomain>}
|
||||
*/
|
||||
/**
|
||||
* @overload
|
||||
* @param {{ subdomain: string, root_dir: string }} options
|
||||
* @returns {Promise<Subdomain>}
|
||||
*/
|
||||
/**
|
||||
* Creates a new subdomain served from the given directory. Rejects if a
|
||||
* subdomain with that name already exists or the path does not exist.
|
||||
*
|
||||
* Accepts `create(subdomain)`, `create(subdomain, dirPath)`, or the object
|
||||
* form `create({ subdomain, root_dir })`. A full host (`sub.puter.site`) is
|
||||
* accepted in place of a bare subdomain and stripped to its label.
|
||||
*
|
||||
* @this {import('./index.js').HostingModule}
|
||||
* @param {string | { subdomain: string, root_dir?: string }} subdomainOrObject
|
||||
* @param {string} [dirPath]
|
||||
* @returns {Promise<Subdomain>}
|
||||
*/
|
||||
export async function create (subdomainOrObject, dirPath) {
|
||||
const { puter } = this;
|
||||
|
||||
let object;
|
||||
if ( typeof subdomainOrObject === 'string' ) {
|
||||
const subdomain = normalizeSubdomain(subdomainOrObject);
|
||||
object = dirPath !== undefined
|
||||
? { subdomain, root_dir: dirPath ? getAbsolutePathForApp(dirPath) : dirPath }
|
||||
: { subdomain };
|
||||
} else {
|
||||
object = subdomainOrObject;
|
||||
}
|
||||
|
||||
return await utils.make_driver_method(['object'], 'puter-subdomains', undefined, 'create', { puter })({ object });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import { normalizeSubdomain } from './lib/args.js';
|
||||
|
||||
/**
|
||||
* Deletes a subdomain from the account; it is no longer served. The associated
|
||||
* directory is disconnected but not deleted. Rejects if the subdomain does not
|
||||
* exist.
|
||||
*
|
||||
* @this {import('./index.js').HostingModule}
|
||||
* @param {string} subdomain
|
||||
* @returns {Promise<{ success: boolean, uid: string }>}
|
||||
*/
|
||||
export async function del (subdomain) {
|
||||
const { puter } = this;
|
||||
|
||||
const options = typeof subdomain === 'string'
|
||||
? { id: { subdomain: normalizeSubdomain(subdomain) } }
|
||||
: {};
|
||||
|
||||
return await utils.make_driver_method(['uid'], 'puter-subdomains', undefined, 'delete', { puter })(options);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import { normalizeSubdomain } from './lib/args.js';
|
||||
|
||||
/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */
|
||||
|
||||
/**
|
||||
* Retrieves a subdomain by name. Rejects if the subdomain does not exist.
|
||||
*
|
||||
* @this {import('./index.js').HostingModule}
|
||||
* @param {string} subdomain
|
||||
* @returns {Promise<Subdomain>}
|
||||
*/
|
||||
export async function get (subdomain) {
|
||||
const { puter } = this;
|
||||
|
||||
const options = typeof subdomain === 'string'
|
||||
? { id: { subdomain: normalizeSubdomain(subdomain) } }
|
||||
: {};
|
||||
|
||||
return await utils.make_driver_method(['uid'], 'puter-subdomains', undefined, 'read', { puter, readonly: true })(options);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { create } from './create.js';
|
||||
import { del } from './delete.js';
|
||||
import { get } from './get.js';
|
||||
import { list } from './list.js';
|
||||
import { update } from './update.js';
|
||||
|
||||
/** @typedef {import('../../../types/puter').Puter} Puter */
|
||||
|
||||
/**
|
||||
* The `puter.hosting` module. Holds a reference to the owning Puter instance
|
||||
* and reads auth state from it live — nothing is copied out, so token and
|
||||
* origin changes on the instance apply to in-flight modules immediately.
|
||||
*
|
||||
* Method implementations live in the sibling files as `this`-context
|
||||
* functions whose JSDoc (including the per-form `@overload` declarations) is
|
||||
* the source of truth for the public signatures; types/modules/hosting.d.ts
|
||||
* mirrors them for TypeScript consumers of the published SDK.
|
||||
*/
|
||||
export class HostingModule {
|
||||
/** @type {Puter} */
|
||||
puter;
|
||||
|
||||
// The fields hold the unbound functions so they keep the full overloaded
|
||||
// types (`bind` erases overloads); the constructor rebinds them at runtime
|
||||
// so destructured calls (`const { create } = puter.hosting`) keep working.
|
||||
list = list;
|
||||
create = create;
|
||||
update = update;
|
||||
get = get;
|
||||
delete = del;
|
||||
|
||||
/** @param {Puter} puter */
|
||||
constructor (puter) {
|
||||
this.puter = puter;
|
||||
|
||||
const methods = /** @type {Record<string, (...args: unknown[]) => unknown>} */ (
|
||||
/** @type {unknown} */ (this)
|
||||
);
|
||||
for ( const name of ['list', 'create', 'update', 'get', 'delete'] ) {
|
||||
methods[name] = methods[name].bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
// Kept for backward compatibility: these used to be copied fields kept in
|
||||
// sync by set{AuthToken,APIOrigin}; they now read through live.
|
||||
get authToken () {
|
||||
return this.puter.authToken;
|
||||
}
|
||||
|
||||
get APIOrigin () {
|
||||
return this.puter.APIOrigin;
|
||||
}
|
||||
|
||||
get appID () {
|
||||
return this.puter.appID;
|
||||
}
|
||||
|
||||
// No-ops: auth state is read from the Puter instance at call time. The
|
||||
// module registry still invokes these on token/origin changes.
|
||||
setAuthToken () {}
|
||||
|
||||
setAPIOrigin () {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public face of the module: derived from the class, with the internal
|
||||
* `puter` handle and the legacy `authToken` accessor omitted.
|
||||
*
|
||||
* @typedef {import('../../lib/types.js').OmitMembers<
|
||||
* typeof HostingModule,
|
||||
* 'puter' | 'authToken'
|
||||
* >} HostingConstructor
|
||||
*/
|
||||
|
||||
export const Hosting = /** @type {HostingConstructor} */ (HostingModule);
|
||||
|
||||
export default Hosting;
|
||||
@@ -0,0 +1,17 @@
|
||||
// Shared argument-shape helpers for the puter.hosting methods.
|
||||
|
||||
// A subdomain may be passed either bare (`example`) or as a full hosted host
|
||||
// (`example.puter.site` / `example.puter.com`); the latter is accepted so
|
||||
// copy-pasting a site URL's host just works during development.
|
||||
const SUBDOMAIN_HOST = /^[a-z0-9]+\.puter\.(site|com)$/;
|
||||
|
||||
/**
|
||||
* Returns just the subdomain label, stripping a trailing `.puter.site` /
|
||||
* `.puter.com` when the value is a full hosted host. Non-string or unrelated
|
||||
* values pass through unchanged.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {unknown}
|
||||
*/
|
||||
export const normalizeSubdomain = (value) =>
|
||||
typeof value === 'string' && SUBDOMAIN_HOST.test(value) ? value.split('.')[0] : value;
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import { fetchAllPages, iteratePages } from '../../lib/pagination.js';
|
||||
import { PuterJSError } from '../../lib/PuterJSError.js';
|
||||
|
||||
/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */
|
||||
|
||||
// Older backends include worker-backed subdomain rows in select results;
|
||||
// current ones exclude them server-side. Filtering here keeps the SDK's output
|
||||
// stable across both.
|
||||
const withoutWorkerRows = (items) =>
|
||||
items.filter(e => !e.subdomain.startsWith('workers.puter.'));
|
||||
|
||||
/**
|
||||
* @overload
|
||||
* @param {import('../../../types/shared').ListStreamOptions} options
|
||||
* @returns {AsyncIterableIterator<import('../../../types/shared').ListPage<Subdomain>>}
|
||||
*/
|
||||
/**
|
||||
* @overload
|
||||
* @param {import('../../../types/shared').ListPaginationOptions & ({ cursor: string | null } | { includeTotal: true })} options
|
||||
* @returns {Promise<import('../../../types/shared').ListPage<Subdomain>>}
|
||||
*/
|
||||
/**
|
||||
* @overload
|
||||
* @param {{ limit?: number, offset?: number }} [options]
|
||||
* @returns {Promise<Subdomain[]>}
|
||||
*/
|
||||
/**
|
||||
* Lists the subdomains the app can access, fetching page by page under the
|
||||
* hood and resolving to a plain array. Passing any pagination param
|
||||
* (`cursor`/`offset`/`includeTotal`) switches to a single-request page
|
||||
* envelope, and `stream: true` returns an async iterator of page envelopes.
|
||||
*
|
||||
* Legacy callback forms `list(success, error)` and `list({ success, error })`
|
||||
* are still honored: the callback fires once with the full result.
|
||||
*
|
||||
* @this {import('./index.js').HostingModule}
|
||||
* @param {...unknown} args
|
||||
* @returns {Promise<Subdomain[]> | Promise<import('../../../types/shared').ListPage<Subdomain>> | AsyncIterableIterator<import('../../../types/shared').ListPage<Subdomain>>}
|
||||
*/
|
||||
export function list (...args) {
|
||||
const { puter } = this;
|
||||
const select = utils.make_driver_method([], 'puter-subdomains', undefined, 'select', { puter, readonly: true });
|
||||
|
||||
const opts = (typeof args[0] === 'object' && args[0] !== null) ? args[0] : {};
|
||||
const { limit, offset, cursor, includeTotal, stream, success, error, ...rest } = opts;
|
||||
const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor');
|
||||
|
||||
const base = { ...rest };
|
||||
if ( limit !== undefined ) base.limit = limit;
|
||||
const fetchPage = pageParams => select({ ...base, ...pageParams });
|
||||
|
||||
if ( stream === true ) {
|
||||
if ( offset !== undefined ) {
|
||||
throw new PuterJSError(
|
||||
'`offset` cannot be combined with `stream`; pass `cursor` to resume from a position.',
|
||||
'invalid_request',
|
||||
);
|
||||
}
|
||||
return (async function* () {
|
||||
for await ( const page of iteratePages(fetchPage, { cursor, includeTotal: includeTotal === true }) ) {
|
||||
yield { ...page, items: withoutWorkerRows(page.items ?? []) };
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Any pagination param keeps the single-request behavior (envelope once
|
||||
// the request opts in via cursor/includeTotal).
|
||||
if ( limit !== undefined || offset !== undefined || hasCursor || includeTotal !== undefined ) {
|
||||
return (async () => {
|
||||
const result = await select(opts);
|
||||
if ( result && !Array.isArray(result) && Array.isArray(result.items) ) {
|
||||
return result;
|
||||
}
|
||||
return withoutWorkerRows(result);
|
||||
})();
|
||||
}
|
||||
|
||||
// Unbound listing: fetch page by page under the hood so no single request
|
||||
// carries the whole result, then return the legacy array.
|
||||
const promise = fetchAllPages(fetchPage).then(items => withoutWorkerRows(items));
|
||||
|
||||
// Legacy callback forms: mirror handle_resp — the callback fires once with
|
||||
// the full result and the returned promise still settles the same way.
|
||||
const successCb = typeof args[0] === 'function' ? args[0] : success;
|
||||
const errorCb = typeof args[0] === 'function' ? args[1] : error;
|
||||
if ( typeof successCb === 'function' || typeof errorCb === 'function' ) {
|
||||
promise.then(
|
||||
result => { if ( typeof successCb === 'function' ) successCb(result); },
|
||||
err => { if ( typeof errorCb === 'function' ) errorCb(err); },
|
||||
);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import getAbsolutePathForApp from '../FileSystem/utils/getAbsolutePathForApp.js';
|
||||
import { normalizeSubdomain } from './lib/args.js';
|
||||
|
||||
/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */
|
||||
|
||||
/**
|
||||
* Updates a subdomain to point at a new directory. Rejects if the subdomain
|
||||
* or the path does not exist. Passing no `dirPath` disconnects the directory.
|
||||
*
|
||||
* @this {import('./index.js').HostingModule}
|
||||
* @param {string} subdomain
|
||||
* @param {string} [dirPath]
|
||||
* @returns {Promise<Subdomain>}
|
||||
*/
|
||||
export async function update (subdomain, dirPath) {
|
||||
const { puter } = this;
|
||||
|
||||
let options = {};
|
||||
if ( typeof subdomain === 'string' ) {
|
||||
const rootDir = dirPath ? getAbsolutePathForApp(dirPath) : (dirPath ?? null);
|
||||
options = { id: { subdomain: normalizeSubdomain(subdomain) }, object: { root_dir: rootDir } };
|
||||
}
|
||||
|
||||
return await utils.make_driver_method(['object'], 'puter-subdomains', undefined, 'update', { puter })(options);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { user } from './user.js';
|
||||
import { version } from './version.js';
|
||||
|
||||
/** @typedef {import('../../../types/puter').Puter} Puter */
|
||||
|
||||
/**
|
||||
* The `puter.os` module. Holds a reference to the owning Puter instance and
|
||||
* reads auth state from it live — nothing is copied out, so token and origin
|
||||
* changes on the instance apply to in-flight modules immediately.
|
||||
*
|
||||
* Method implementations live in the sibling files as `this`-context
|
||||
* functions whose JSDoc is the source of truth for the public signatures;
|
||||
* types/modules/os.d.ts mirrors them for TypeScript consumers of the SDK.
|
||||
*/
|
||||
export class OSModule {
|
||||
/** @type {Puter} */
|
||||
puter;
|
||||
|
||||
user = user;
|
||||
version = version;
|
||||
|
||||
/** @param {Puter} puter */
|
||||
constructor (puter) {
|
||||
this.puter = puter;
|
||||
|
||||
const methods = /** @type {Record<string, (...args: unknown[]) => unknown>} */ (
|
||||
/** @type {unknown} */ (this)
|
||||
);
|
||||
for ( const name of ['user', 'version'] ) {
|
||||
methods[name] = methods[name].bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
// Kept for backward compatibility: these used to be copied fields kept in
|
||||
// sync by set{AuthToken,APIOrigin}; they now read through live.
|
||||
get authToken () {
|
||||
return this.puter.authToken;
|
||||
}
|
||||
|
||||
get APIOrigin () {
|
||||
return this.puter.APIOrigin;
|
||||
}
|
||||
|
||||
get appID () {
|
||||
return this.puter.appID;
|
||||
}
|
||||
|
||||
// No-ops: auth state is read from the Puter instance at call time. The
|
||||
// module registry still invokes these on token/origin changes.
|
||||
setAuthToken () {}
|
||||
|
||||
setAPIOrigin () {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public face of the module: derived from the class, with the internal
|
||||
* `puter` handle and the legacy `authToken` accessor omitted.
|
||||
*
|
||||
* @typedef {import('../../lib/types.js').OmitMembers<
|
||||
* typeof OSModule,
|
||||
* 'puter' | 'authToken'
|
||||
* >} OSConstructor
|
||||
*/
|
||||
|
||||
export const OS = /** @type {OSConstructor} */ (OSModule);
|
||||
|
||||
export default OS;
|
||||
@@ -0,0 +1,11 @@
|
||||
// Shared argument parsing for the puter.os methods. Each accepts either a
|
||||
// single options object or trailing positional `success`/`error` callbacks.
|
||||
|
||||
/**
|
||||
* @param {unknown[]} args
|
||||
* @returns {{ success?: Function, error?: Function, query?: Record<string, string> }}
|
||||
*/
|
||||
export const parseCallbackOptions = (args) =>
|
||||
(typeof args[0] === 'object' && args[0] !== null)
|
||||
? args[0]
|
||||
: { success: args[0], error: args[1] };
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import { parseCallbackOptions } from './lib/args.js';
|
||||
|
||||
/** @typedef {import('../../../types/modules/auth').User} User */
|
||||
|
||||
/**
|
||||
* Returns the currently authenticated user. Accepts an options object with an
|
||||
* optional `query` (forwarded as query-string params to `/whoami`) and
|
||||
* `success`/`error` callbacks, or trailing positional callbacks.
|
||||
*
|
||||
* @this {import('./index.js').OSModule}
|
||||
* @param {...(((value: User) => void) | { success?: Function, error?: Function, query?: Record<string, string> })} args
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
export function user (...args) {
|
||||
const { puter } = this;
|
||||
const options = parseCallbackOptions(args);
|
||||
|
||||
let query = '';
|
||||
if ( options?.query ) {
|
||||
query = `?${new URLSearchParams(options.query).toString()}`;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = utils.initXhr(`/whoami${query}`, puter.APIOrigin, puter.authToken, 'get');
|
||||
utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject);
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as utils from '../../lib/utils.js';
|
||||
import { parseCallbackOptions } from './lib/args.js';
|
||||
|
||||
/**
|
||||
* Returns version information about the Puter deployment. Accepts an options
|
||||
* object with `success`/`error` callbacks, or trailing positional callbacks.
|
||||
*
|
||||
* @this {import('./index.js').OSModule}
|
||||
* @param {...(((value: Record<string, unknown>) => void) | { success?: Function, error?: Function })} args
|
||||
* @returns {Promise<Record<string, unknown>>}
|
||||
*/
|
||||
export function version (...args) {
|
||||
const { puter } = this;
|
||||
const options = parseCallbackOptions(args);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = utils.initXhr('/version', puter.APIOrigin, puter.authToken, 'get');
|
||||
utils.setupXhrEventHandlers(xhr, options.success, options.error, resolve, reject);
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { PuterJSError } from '../../lib/PuterJSError.js';
|
||||
import { req } from './lib/req.js';
|
||||
|
||||
/** @typedef {import('./index.js').PermsModule} PermsModule */
|
||||
|
||||
/**
|
||||
* Requests access at the given level to the root directory of one of the
|
||||
* user's apps. Tries the request first; if it fails, prompts for the
|
||||
* permission and retries (with a short backoff to ride out server-side cache
|
||||
* invalidation), returning the fs item on success or `undefined` if denied.
|
||||
*
|
||||
* @param {import('../../../types/puter').Puter} puter
|
||||
* @param {'read' | 'write'} access
|
||||
* @param {string | { uid: string }} appUidOrObject
|
||||
* @returns {Promise<Record<string, unknown> | undefined>}
|
||||
*/
|
||||
async function requestAppRootDir (puter, access, appUidOrObject) {
|
||||
const appUid = (typeof appUidOrObject === 'object' && appUidOrObject !== null)
|
||||
? appUidOrObject.uid
|
||||
: appUidOrObject;
|
||||
|
||||
if ( typeof appUid !== 'string' ) {
|
||||
throw new PuterJSError('parameter app_uid must be a string', 'invalid_argument');
|
||||
}
|
||||
|
||||
let result;
|
||||
const fetchIt = async () => {
|
||||
result = await req(puter, '/auth/request-app-root-dir', { app_uid: appUid, access });
|
||||
};
|
||||
|
||||
await fetchIt();
|
||||
if ( ! result.error ) return result;
|
||||
|
||||
const granted = await puter.ui.requestPermission({
|
||||
permission: `app-root-dir:${appUid}:${access}`,
|
||||
});
|
||||
|
||||
if ( granted ) {
|
||||
await fetchIt();
|
||||
|
||||
// If the server has cache-invalidation lag, retry with backoff so this
|
||||
// still works. A hack, but also a reasonable safeguard.
|
||||
let delay = 100;
|
||||
const maxTotalWait = 5000;
|
||||
let totalWaited = 0;
|
||||
while ( result.error && totalWaited < maxTotalWait ) {
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
totalWaited += delay;
|
||||
await fetchIt();
|
||||
if ( ! result.error ) break;
|
||||
delay = Math.min(delay * 2, Math.max(100, maxTotalWait - totalWaited));
|
||||
}
|
||||
}
|
||||
|
||||
return result.error ? undefined : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request read access to the root directory of one of the user's apps.
|
||||
*
|
||||
* @this {PermsModule}
|
||||
* @param {string | { uid: string }} appUid - The app uid, or an object with a `uid`.
|
||||
* @returns {Promise<Record<string, unknown> | undefined>} The directory fs item, or `undefined` if denied.
|
||||
*/
|
||||
export async function requestReadAppRootDir (appUid) {
|
||||
return await requestAppRootDir(this.puter, 'read', appUid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request write access to the root directory of one of the user's apps.
|
||||
*
|
||||
* @this {PermsModule}
|
||||
* @param {string | { uid: string }} appUid - The app uid, or an object with a `uid`.
|
||||
* @returns {Promise<Record<string, unknown> | undefined>} The directory fs item, or `undefined` if denied.
|
||||
*/
|
||||
export async function requestWriteAppRootDir (appUid) {
|
||||
return await requestAppRootDir(this.puter, 'write', appUid);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Control the shared request helper so these run without a server or UI.
|
||||
const mockReq = vi.fn();
|
||||
vi.mock('./lib/req.js', () => ({ req: (...args) => mockReq(...args) }));
|
||||
|
||||
const { requestReadAppRootDir, requestWriteAppRootDir } = await import('./appRootDir.js');
|
||||
const { PuterJSError } = await import('../../lib/PuterJSError.js');
|
||||
|
||||
const makeModule = (requestPermission) => ({
|
||||
puter: {
|
||||
APIOrigin: 'https://api.test',
|
||||
ui: { requestPermission: vi.fn(requestPermission) },
|
||||
},
|
||||
});
|
||||
|
||||
describe('perms appRootDir', () => {
|
||||
beforeEach(() => mockReq.mockReset());
|
||||
|
||||
it('requests write access with access:"write" in the body', async () => {
|
||||
mockReq.mockResolvedValueOnce({ path: '/root' }); // succeeds first try
|
||||
const mod = makeModule();
|
||||
|
||||
const result = await requestWriteAppRootDir.call(mod, 'app-123');
|
||||
|
||||
expect(result).toEqual({ path: '/root' });
|
||||
expect(mockReq).toHaveBeenCalledWith(
|
||||
mod.puter,
|
||||
'/auth/request-app-root-dir',
|
||||
{ app_uid: 'app-123', access: 'write' },
|
||||
);
|
||||
// Already had access, so no permission prompt.
|
||||
expect(mod.puter.ui.requestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests read access with access:"read" in the body', async () => {
|
||||
mockReq.mockResolvedValueOnce({ path: '/root' });
|
||||
const mod = makeModule();
|
||||
|
||||
await requestReadAppRootDir.call(mod, 'app-xyz');
|
||||
|
||||
expect(mockReq).toHaveBeenCalledWith(
|
||||
mod.puter,
|
||||
'/auth/request-app-root-dir',
|
||||
{ app_uid: 'app-xyz', access: 'read' },
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for the write permission string and retries after a grant', async () => {
|
||||
// First call is denied by the backend, second (post-grant) succeeds.
|
||||
mockReq
|
||||
.mockResolvedValueOnce({ error: true })
|
||||
.mockResolvedValueOnce({ path: '/root' });
|
||||
const mod = makeModule(() => true);
|
||||
|
||||
const result = await requestWriteAppRootDir.call(mod, 'app-123');
|
||||
|
||||
expect(mod.puter.ui.requestPermission).toHaveBeenCalledWith({
|
||||
permission: 'app-root-dir:app-123:write',
|
||||
});
|
||||
expect(result).toEqual({ path: '/root' });
|
||||
expect(mockReq).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('accepts an app object with a uid', async () => {
|
||||
mockReq.mockResolvedValueOnce({ path: '/root' });
|
||||
const mod = makeModule();
|
||||
|
||||
await requestReadAppRootDir.call(mod, { uid: 'app-obj' });
|
||||
|
||||
expect(mockReq).toHaveBeenCalledWith(
|
||||
mod.puter,
|
||||
'/auth/request-app-root-dir',
|
||||
{ app_uid: 'app-obj', access: 'read' },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when the permission is denied', async () => {
|
||||
mockReq.mockResolvedValue({ error: true });
|
||||
const mod = makeModule(() => false);
|
||||
|
||||
const result = await requestWriteAppRootDir.call(mod, 'app-123');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a non-string, non-object app uid with a coded error', async () => {
|
||||
const mod = makeModule();
|
||||
await expect(requestReadAppRootDir.call(mod, 42)).rejects.toBeInstanceOf(PuterJSError);
|
||||
await expect(requestReadAppRootDir.call(mod, 42)).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/** @typedef {import('./index.js').PermsModule} PermsModule */
|
||||
|
||||
/**
|
||||
* Requests access to one of the user's special folders, returning its path if
|
||||
* access is (or becomes) granted. Read access is inferred from being able to
|
||||
* stat the folder; write access always prompts if not already held.
|
||||
*
|
||||
* @this {PermsModule}
|
||||
* @param {string} folderName - Desktop, Documents, Pictures, or Videos.
|
||||
* @param {'read' | 'write'} accessLevel
|
||||
* @returns {Promise<string | undefined>}
|
||||
*/
|
||||
export async function requestFolder_ (folderName, accessLevel) {
|
||||
const whoami = await this.puter.auth.whoami();
|
||||
const folderPath = `/${whoami.username}/${folderName}`;
|
||||
|
||||
// Being able to stat the folder means we already have at least read access.
|
||||
try {
|
||||
await this.puter.fs.stat({ path: folderPath });
|
||||
if ( accessLevel !== 'write' ) {
|
||||
return folderPath;
|
||||
}
|
||||
} catch (e) {
|
||||
// No access yet, fall through to request permission.
|
||||
}
|
||||
|
||||
const granted = await this.puter.ui.requestPermission({
|
||||
permission: `fs:${folderPath}:${accessLevel}`,
|
||||
});
|
||||
return granted ? folderPath : undefined;
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
export function requestReadDesktop () {
|
||||
return this.requestFolder_('Desktop', 'read');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
export function requestWriteDesktop () {
|
||||
return this.requestFolder_('Desktop', 'write');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
export function requestReadDocuments () {
|
||||
return this.requestFolder_('Documents', 'read');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
export function requestWriteDocuments () {
|
||||
return this.requestFolder_('Documents', 'write');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
export function requestReadPictures () {
|
||||
return this.requestFolder_('Pictures', 'read');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
export function requestWritePictures () {
|
||||
return this.requestFolder_('Pictures', 'write');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
export function requestReadVideos () {
|
||||
return this.requestFolder_('Videos', 'read');
|
||||
}
|
||||
|
||||
/** @this {PermsModule} @returns {Promise<string | undefined>} */
|
||||
export function requestWriteVideos () {
|
||||
return this.requestFolder_('Videos', 'write');
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { req } from './lib/req.js';
|
||||
|
||||
/** @typedef {import('./index.js').PermsModule} PermsModule */
|
||||
/** @typedef {Promise<Record<string, unknown>>} PermResult */
|
||||
|
||||
// -- Grant --
|
||||
|
||||
/**
|
||||
* Grants a permission to another user.
|
||||
* @this {PermsModule}
|
||||
* @param {string} username
|
||||
* @param {string} permission
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function grantUser (username, permission) {
|
||||
return await req(this.puter, '/auth/grant-user-user', { target_username: username, permission });
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants a permission to a group.
|
||||
* @this {PermsModule}
|
||||
* @param {string} groupUid
|
||||
* @param {string} permission
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function grantGroup (groupUid, permission) {
|
||||
return await req(this.puter, '/auth/grant-user-group', { group_uid: groupUid, permission });
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants a permission to an app.
|
||||
* @this {PermsModule}
|
||||
* @param {string} appUid
|
||||
* @param {string} permission
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function grantApp (appUid, permission) {
|
||||
return await req(this.puter, '/auth/grant-user-app', { app_uid: appUid, permission });
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants a permission to an app for any user (developer grant).
|
||||
* @this {PermsModule}
|
||||
* @param {string} appUid
|
||||
* @param {string} permission
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function grantAppAnyUser (appUid, permission) {
|
||||
return await req(this.puter, '/auth/grant-dev-app', { app_uid: appUid, permission });
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants a permission to an origin.
|
||||
* @this {PermsModule}
|
||||
* @param {string} origin
|
||||
* @param {string} permission
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function grantOrigin (origin, permission) {
|
||||
return await req(this.puter, '/auth/grant-user-app', { origin, permission });
|
||||
}
|
||||
|
||||
// -- Revoke --
|
||||
|
||||
/**
|
||||
* Revokes a permission from another user.
|
||||
* @this {PermsModule}
|
||||
* @param {string} username
|
||||
* @param {string} permission
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function revokeUser (username, permission) {
|
||||
return await req(this.puter, '/auth/revoke-user-user', { target_username: username, permission });
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a permission from a group.
|
||||
* @this {PermsModule}
|
||||
* @param {string} groupUid
|
||||
* @param {string} permission
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function revokeGroup (groupUid, permission) {
|
||||
return await req(this.puter, '/auth/revoke-user-group', { group_uid: groupUid, permission });
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a permission from an app.
|
||||
* @this {PermsModule}
|
||||
* @param {string} appUid
|
||||
* @param {string} permission
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function revokeApp (appUid, permission) {
|
||||
return await req(this.puter, '/auth/revoke-user-app', { app_uid: appUid, permission });
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes an app's any-user (developer) permission.
|
||||
* @this {PermsModule}
|
||||
* @param {string} appUid
|
||||
* @param {string} permission
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function revokeAppAnyUser (appUid, permission) {
|
||||
return await req(this.puter, '/auth/revoke-dev-app', { app_uid: appUid, permission });
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes a permission from an origin.
|
||||
* @this {PermsModule}
|
||||
* @param {string} origin
|
||||
* @param {string} permission
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function revokeOrigin (origin, permission) {
|
||||
return await req(this.puter, '/auth/revoke-user-app', { origin, permission });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { req } from './lib/req.js';
|
||||
|
||||
/** @typedef {import('./index.js').PermsModule} PermsModule */
|
||||
/** @typedef {Promise<Record<string, unknown>>} PermResult */
|
||||
|
||||
/**
|
||||
* Creates a new group.
|
||||
* @this {PermsModule}
|
||||
* @param {Record<string, unknown>} [metadata]
|
||||
* @param {Record<string, unknown>} [extra]
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function createGroup (metadata = {}, extra = {}) {
|
||||
return await req(this.puter, '/group/create', { metadata, extra });
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds users to a group by username.
|
||||
* @this {PermsModule}
|
||||
* @param {string} uid
|
||||
* @param {string[]} usernames
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function addUsersToGroup (uid, usernames) {
|
||||
return await req(this.puter, '/group/add-users', { uid, users: usernames ?? [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes users from a group by username.
|
||||
* @this {PermsModule}
|
||||
* @param {string} uid
|
||||
* @param {string[]} usernames
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function removeUsersFromGroup (uid, usernames) {
|
||||
return await req(this.puter, '/group/remove-users', { uid, users: usernames ?? [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the caller's groups.
|
||||
* @this {PermsModule}
|
||||
* @returns {PermResult}
|
||||
*/
|
||||
export async function listGroups () {
|
||||
return await req(this.puter, '/group/list');
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { requestReadAppRootDir, requestWriteAppRootDir } from './appRootDir.js';
|
||||
import {
|
||||
requestFolder_,
|
||||
requestReadDesktop, requestWriteDesktop,
|
||||
requestReadDocuments, requestWriteDocuments,
|
||||
requestReadPictures, requestWritePictures,
|
||||
requestReadVideos, requestWriteVideos,
|
||||
} from './folders.js';
|
||||
import {
|
||||
grantApp, grantAppAnyUser, grantGroup, grantOrigin, grantUser,
|
||||
revokeApp, revokeAppAnyUser, revokeGroup, revokeOrigin, revokeUser,
|
||||
} from './grants.js';
|
||||
import { addUsersToGroup, createGroup, listGroups, removeUsersFromGroup } from './groups.js';
|
||||
import { req } from './lib/req.js';
|
||||
import {
|
||||
request, requestEmail, requestManageApps, requestManageSubdomains,
|
||||
requestPermission, requestReadApps, requestReadSubdomains,
|
||||
} from './permissions.js';
|
||||
|
||||
/** @typedef {import('../../../types/puter').Puter} Puter */
|
||||
|
||||
// Every `this`-context method exposed on the module, rebound in the
|
||||
// constructor so both `puter.perms.grantUser(...)` and destructured
|
||||
// `const { grantUser } = puter.perms` calls keep the right `this`.
|
||||
const METHODS = [
|
||||
'grantUser', 'grantGroup', 'grantApp', 'grantAppAnyUser', 'grantOrigin',
|
||||
'revokeUser', 'revokeGroup', 'revokeApp', 'revokeAppAnyUser', 'revokeOrigin',
|
||||
'createGroup', 'addUsersToGroup', 'removeUsersFromGroup', 'listGroups',
|
||||
'request', 'requestPermission', 'requestEmail',
|
||||
'requestReadApps', 'requestManageApps', 'requestReadSubdomains', 'requestManageSubdomains',
|
||||
'requestFolder_',
|
||||
'requestReadDesktop', 'requestWriteDesktop',
|
||||
'requestReadDocuments', 'requestWriteDocuments',
|
||||
'requestReadPictures', 'requestWritePictures',
|
||||
'requestReadVideos', 'requestWriteVideos',
|
||||
'requestReadAppRootDir', 'requestWriteAppRootDir',
|
||||
];
|
||||
|
||||
/**
|
||||
* The `puter.perms` module. Holds a reference to the owning Puter instance and
|
||||
* reads auth state from it live — nothing is copied out, so token and origin
|
||||
* changes on the instance apply to in-flight modules immediately.
|
||||
*
|
||||
* Method implementations live in the sibling files as `this`-context
|
||||
* functions whose JSDoc is the source of truth for the public signatures;
|
||||
* types/modules/perms.d.ts mirrors them for TypeScript consumers of the SDK.
|
||||
*/
|
||||
export class PermsModule {
|
||||
/** @type {Puter} */
|
||||
puter;
|
||||
|
||||
// Grant / revoke
|
||||
grantUser = grantUser;
|
||||
grantGroup = grantGroup;
|
||||
grantApp = grantApp;
|
||||
grantAppAnyUser = grantAppAnyUser;
|
||||
grantOrigin = grantOrigin;
|
||||
revokeUser = revokeUser;
|
||||
revokeGroup = revokeGroup;
|
||||
revokeApp = revokeApp;
|
||||
revokeAppAnyUser = revokeAppAnyUser;
|
||||
revokeOrigin = revokeOrigin;
|
||||
|
||||
// Group management
|
||||
createGroup = createGroup;
|
||||
addUsersToGroup = addUsersToGroup;
|
||||
removeUsersFromGroup = removeUsersFromGroup;
|
||||
listGroups = listGroups;
|
||||
|
||||
// Permission requests
|
||||
request = request;
|
||||
requestPermission = requestPermission;
|
||||
requestEmail = requestEmail;
|
||||
requestReadApps = requestReadApps;
|
||||
requestManageApps = requestManageApps;
|
||||
requestReadSubdomains = requestReadSubdomains;
|
||||
requestManageSubdomains = requestManageSubdomains;
|
||||
|
||||
// Folder access
|
||||
requestFolder_ = requestFolder_;
|
||||
requestReadDesktop = requestReadDesktop;
|
||||
requestWriteDesktop = requestWriteDesktop;
|
||||
requestReadDocuments = requestReadDocuments;
|
||||
requestWriteDocuments = requestWriteDocuments;
|
||||
requestReadPictures = requestReadPictures;
|
||||
requestWritePictures = requestWritePictures;
|
||||
requestReadVideos = requestReadVideos;
|
||||
requestWriteVideos = requestWriteVideos;
|
||||
|
||||
// App root directory access
|
||||
requestReadAppRootDir = requestReadAppRootDir;
|
||||
requestWriteAppRootDir = requestWriteAppRootDir;
|
||||
|
||||
/** @param {Puter} puter */
|
||||
constructor (puter) {
|
||||
this.puter = puter;
|
||||
|
||||
const methods = /** @type {Record<string, (...args: unknown[]) => unknown>} */ (
|
||||
/** @type {unknown} */ (this)
|
||||
);
|
||||
for ( const name of METHODS ) {
|
||||
methods[name] = methods[name].bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level request helper against the auth/group endpoints, kept on the
|
||||
* instance for backward compatibility. Returns the parsed result object
|
||||
* (with `error: true` set on failure) rather than rejecting.
|
||||
*
|
||||
* @param {string} route
|
||||
* @param {Record<string, unknown>} [body]
|
||||
* @returns {Promise<Record<string, unknown>>}
|
||||
*/
|
||||
req_ (route, body) {
|
||||
return req(this.puter, route, body);
|
||||
}
|
||||
|
||||
// Kept for backward compatibility: these used to be copied fields kept in
|
||||
// sync by set{AuthToken,APIOrigin}; they now read through live.
|
||||
get authToken () {
|
||||
return this.puter.authToken;
|
||||
}
|
||||
|
||||
get APIOrigin () {
|
||||
return this.puter.APIOrigin;
|
||||
}
|
||||
|
||||
// No-ops: auth state is read from the Puter instance at call time. The
|
||||
// module registry still invokes these on token/origin changes.
|
||||
setAuthToken () {}
|
||||
|
||||
setAPIOrigin () {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public face of the module: derived from the class, with the internal
|
||||
* `puter` handle and the legacy `authToken` accessor omitted.
|
||||
*
|
||||
* @typedef {import('../../lib/types.js').OmitMembers<
|
||||
* typeof PermsModule,
|
||||
* 'puter' | 'authToken'
|
||||
* >} PermsConstructor
|
||||
*/
|
||||
|
||||
export const Perms = /** @type {PermsConstructor} */ (PermsModule);
|
||||
|
||||
export default Perms;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fetchUrl } from '../../../lib/networkUtils.js';
|
||||
|
||||
/**
|
||||
* Shared request helper for the grant/revoke/group endpoints. These endpoints
|
||||
* return a parsed result object (with `error: true` set on failure) rather
|
||||
* than rejecting — preserved for backward compatibility, so callers keep
|
||||
* inspecting `result.error` instead of catching.
|
||||
*
|
||||
* @param {import('../../../../types/puter').Puter} puter
|
||||
* @param {string} route
|
||||
* @param {Record<string, unknown>} [body] - When present the request is a POST.
|
||||
* @returns {Promise<Record<string, unknown>>}
|
||||
*/
|
||||
export async function req (puter, route, body) {
|
||||
try {
|
||||
const resp = await fetchUrl(puter.APIOrigin + route, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
includePuterAuth: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
if ( resp.headers.get('content-type')?.includes('application/json') ) {
|
||||
const jsonResult = await resp.json();
|
||||
if ( resp.status !== 200 ) {
|
||||
jsonResult.error = true;
|
||||
}
|
||||
return jsonResult;
|
||||
}
|
||||
return { error: true, message: await resp.text(), code: 'unknown_error' };
|
||||
} catch (e) {
|
||||
return { error: true, message: e.message, code: 'internal_error' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/** @typedef {import('./index.js').PermsModule} PermsModule */
|
||||
|
||||
/**
|
||||
* Request a specific permission string to be granted. Note that some
|
||||
* permission strings are not supported and will be denied silently.
|
||||
*
|
||||
* @this {PermsModule}
|
||||
* @param {string} permission - The permission string to request.
|
||||
* @returns {Promise<boolean>} `true` if the permission was granted.
|
||||
*/
|
||||
export async function request (permission) {
|
||||
// Note: this cannot move fully off of `puter.ui` without a significant
|
||||
// refactor, because the UI module owns all of the IPC communication logic.
|
||||
return await this.puter.ui.requestPermission({ permission });
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link request} instead.
|
||||
* @this {PermsModule}
|
||||
* @param {...unknown} args
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export function requestPermission (...args) {
|
||||
return this.request(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to see the user's email. If already granted, the user is not
|
||||
* prompted and their email is returned.
|
||||
*
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<string | null | undefined>} The email if granted, `null`
|
||||
* if granted but no email is on file, or `undefined` if access is denied.
|
||||
*/
|
||||
export async function requestEmail () {
|
||||
let whoami = await this.puter.auth.whoami();
|
||||
if ( whoami.email !== undefined ) return whoami.email;
|
||||
|
||||
const granted = await this.puter.ui.requestPermission({
|
||||
permission: `user:${whoami.uuid}:email:read`,
|
||||
});
|
||||
if ( granted ) {
|
||||
whoami = await this.puter.auth.whoami();
|
||||
}
|
||||
return whoami.email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request read access to the user's apps.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function requestReadApps () {
|
||||
const whoami = await this.puter.auth.whoami();
|
||||
return await this.puter.ui.requestPermission({ permission: `apps-of-user:${whoami.uuid}:read` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Request write (manage) access to the user's apps.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function requestManageApps () {
|
||||
const whoami = await this.puter.auth.whoami();
|
||||
return await this.puter.ui.requestPermission({ permission: `apps-of-user:${whoami.uuid}:write` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Request read access to the user's subdomains.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function requestReadSubdomains () {
|
||||
const whoami = await this.puter.auth.whoami();
|
||||
return await this.puter.ui.requestPermission({ permission: `subdomains-of-user:${whoami.uuid}:read` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Request write (manage) access to the user's subdomains.
|
||||
* @this {PermsModule}
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function requestManageSubdomains () {
|
||||
const whoami = await this.puter.auth.whoami();
|
||||
return await this.puter.ui.requestPermission({ permission: `subdomains-of-user:${whoami.uuid}:write` });
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* eslint-disable */
|
||||
// TODO: Make these more compatible with eslint
|
||||
// Hand-run these in the browser harness. They create and delete real apps
|
||||
// under the signed-in account, using unique names so reruns don't collide.
|
||||
const appsName = (s) => `apps-test-${s}-${Date.now()}`;
|
||||
|
||||
window.appsTests = [
|
||||
{
|
||||
name: "testCreateAndGet",
|
||||
description: "Create an app (positional form) and read it back by name",
|
||||
test: async function() {
|
||||
const name = appsName('create');
|
||||
try {
|
||||
const app = await puter.apps.create(name, 'https://example.com/create');
|
||||
assert(app.name === name, "created app name mismatch");
|
||||
const fetched = await puter.apps.get(name);
|
||||
assert(fetched.index_url === 'https://example.com/create', "index_url mismatch");
|
||||
pass("testCreateAndGet passed");
|
||||
} catch (error) {
|
||||
fail("testCreateAndGet failed:", error);
|
||||
} finally {
|
||||
try { await puter.apps.delete(name); } catch (e) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testCreateOptionsRemap",
|
||||
description: "Create with camelCase options and verify snake_case fields are stored",
|
||||
test: async function() {
|
||||
const name = appsName('remap');
|
||||
try {
|
||||
await puter.apps.create({
|
||||
name,
|
||||
indexURL: 'https://example.com/remap',
|
||||
maximizeOnStart: true,
|
||||
filetypeAssociations: ['.txt', 'image/png'],
|
||||
});
|
||||
const fetched = await puter.apps.get(name);
|
||||
assert(Boolean(fetched.maximize_on_start) === true, "maximize_on_start not stored");
|
||||
assert(JSON.stringify(fetched.filetype_associations) === JSON.stringify(['.txt', 'image/png']), "filetype_associations not stored");
|
||||
pass("testCreateOptionsRemap passed");
|
||||
} catch (error) {
|
||||
fail("testCreateOptionsRemap failed:", error);
|
||||
} finally {
|
||||
try { await puter.apps.delete(name); } catch (e) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testCreateValidationErrorShape",
|
||||
description: "Create without a name throws a backward-compatible { code, error:{code} } error",
|
||||
test: async function() {
|
||||
try {
|
||||
await puter.apps.create({ indexURL: 'https://example.com/x' });
|
||||
fail("testCreateValidationErrorShape failed: no error thrown");
|
||||
} catch (error) {
|
||||
assert(error.code === 'invalid_request', "top-level code should be invalid_request");
|
||||
assert(error.error && error.error.code === 'invalid_request', "legacy nested error.code should be invalid_request");
|
||||
assert(error instanceof Error, "error should be an Error instance");
|
||||
pass("testCreateValidationErrorShape passed: " + error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testUpdate",
|
||||
description: "Update an app's index URL and verify the change",
|
||||
test: async function() {
|
||||
const name = appsName('update');
|
||||
try {
|
||||
await puter.apps.create(name, 'https://example.com/before');
|
||||
const updated = await puter.apps.update(name, { indexURL: 'https://example.com/after' });
|
||||
assert(updated.index_url === 'https://example.com/after', "index_url not updated");
|
||||
pass("testUpdate passed");
|
||||
} catch (error) {
|
||||
fail("testUpdate failed:", error);
|
||||
} finally {
|
||||
try { await puter.apps.delete(name); } catch (e) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testList",
|
||||
description: "List apps and confirm a freshly created app appears",
|
||||
test: async function() {
|
||||
const name = appsName('list');
|
||||
try {
|
||||
await puter.apps.create(name, 'https://example.com/list');
|
||||
const apps = await puter.apps.list();
|
||||
assert(Array.isArray(apps), "list should resolve to an array");
|
||||
assert(apps.some(a => a.name === name), "created app should appear in list");
|
||||
pass("testList passed");
|
||||
} catch (error) {
|
||||
fail("testList failed:", error);
|
||||
} finally {
|
||||
try { await puter.apps.delete(name); } catch (e) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testCheckName",
|
||||
description: "checkName reports a taken name differently from an available one",
|
||||
test: async function() {
|
||||
const name = appsName('checkname');
|
||||
try {
|
||||
await puter.apps.create(name, 'https://example.com/cn');
|
||||
const taken = await puter.apps.checkName(name);
|
||||
const available = await puter.apps.checkName(appsName('surely-free'));
|
||||
assert(JSON.stringify(taken) !== JSON.stringify(available), "taken and available should differ");
|
||||
pass("testCheckName passed");
|
||||
} catch (error) {
|
||||
fail("testCheckName failed:", error);
|
||||
} finally {
|
||||
try { await puter.apps.delete(name); } catch (e) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testGetDeveloperProfile",
|
||||
description: "getDeveloperProfile resolves once with an object (single-parse fix)",
|
||||
test: async function() {
|
||||
try {
|
||||
const profile = await puter.apps.getDeveloperProfile();
|
||||
assert(profile && typeof profile === 'object', "profile should be an object");
|
||||
pass("testGetDeveloperProfile passed");
|
||||
} catch (error) {
|
||||
fail("testGetDeveloperProfile failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable */
|
||||
// TODO: Make these more compatible with eslint
|
||||
// Hand-run in the browser harness. Each test provisions its own root directory
|
||||
// (the subdomain driver requires one) and cleans up the subdomain afterward.
|
||||
const hostName = (s) => `hostingtest${s}${Date.now()}`;
|
||||
|
||||
async function makeDir(name) {
|
||||
const user = await puter.auth.getUser();
|
||||
const dir = `/${user.username}/hosting-test-${name}-${Date.now()}`;
|
||||
await puter.fs.mkdir(dir, { createMissingParents: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
window.hostingTests = [
|
||||
{
|
||||
name: "testCreateAndGet",
|
||||
description: "Create a subdomain and read it back by name",
|
||||
test: async function() {
|
||||
const sub = hostName('cg');
|
||||
try {
|
||||
const dir = await makeDir('cg');
|
||||
const created = await puter.hosting.create(sub, dir);
|
||||
assert(created.subdomain === sub, "created subdomain mismatch");
|
||||
const fetched = await puter.hosting.get(sub);
|
||||
assert(fetched.subdomain === sub, "fetched subdomain mismatch");
|
||||
pass("testCreateAndGet passed");
|
||||
} catch (error) {
|
||||
fail("testCreateAndGet failed:", error);
|
||||
} finally {
|
||||
try { await puter.hosting.delete(sub); } catch (e) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testFullHostNormalization",
|
||||
description: "Passing '<name>.puter.site' stores just the label and is retrievable by it",
|
||||
test: async function() {
|
||||
const sub = hostName('fh');
|
||||
try {
|
||||
const dir = await makeDir('fh');
|
||||
const created = await puter.hosting.create(`${sub}.puter.site`, dir);
|
||||
assert(created.subdomain === sub, "full host should be stripped to the label");
|
||||
const byHost = await puter.hosting.get(`${sub}.puter.com`);
|
||||
assert(byHost.subdomain === sub, "get should normalize a full host too");
|
||||
pass("testFullHostNormalization passed");
|
||||
} catch (error) {
|
||||
fail("testFullHostNormalization failed:", error);
|
||||
} finally {
|
||||
try { await puter.hosting.delete(sub); } catch (e) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testUpdate",
|
||||
description: "Repoint a subdomain to a new directory",
|
||||
test: async function() {
|
||||
const sub = hostName('up');
|
||||
try {
|
||||
const dirA = await makeDir('up-a');
|
||||
const dirB = await makeDir('up-b');
|
||||
await puter.hosting.create(sub, dirA);
|
||||
const updated = await puter.hosting.update(sub, dirB);
|
||||
assert(updated.subdomain === sub, "update should return the subdomain");
|
||||
pass("testUpdate passed");
|
||||
} catch (error) {
|
||||
fail("testUpdate failed:", error);
|
||||
} finally {
|
||||
try { await puter.hosting.delete(sub); } catch (e) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testList",
|
||||
description: "List subdomains and confirm a created one appears",
|
||||
test: async function() {
|
||||
const sub = hostName('ls');
|
||||
try {
|
||||
const dir = await makeDir('ls');
|
||||
await puter.hosting.create(sub, dir);
|
||||
const sites = await puter.hosting.list();
|
||||
assert(Array.isArray(sites), "list should resolve to an array");
|
||||
assert(sites.some(s => s.subdomain === sub), "created subdomain should appear");
|
||||
pass("testList passed");
|
||||
} catch (error) {
|
||||
fail("testList failed:", error);
|
||||
} finally {
|
||||
try { await puter.hosting.delete(sub); } catch (e) {}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testDelete",
|
||||
description: "Delete a subdomain and confirm get then rejects",
|
||||
test: async function() {
|
||||
const sub = hostName('del');
|
||||
try {
|
||||
const dir = await makeDir('del');
|
||||
await puter.hosting.create(sub, dir);
|
||||
await puter.hosting.delete(sub);
|
||||
try {
|
||||
await puter.hosting.get(sub);
|
||||
fail("testDelete failed: get of a deleted subdomain resolved");
|
||||
} catch (e) {
|
||||
pass("testDelete passed");
|
||||
}
|
||||
} catch (error) {
|
||||
fail("testDelete failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -8,6 +8,10 @@
|
||||
<script src="./txt2speech.test.js"></script>
|
||||
<script src="./txt2img.test.js"></script>
|
||||
<script src="./txt2vid.test.js"></script>
|
||||
<script src="./hosting.test.js"></script>
|
||||
<script src="./apps.test.js"></script>
|
||||
<script src="./os.test.js"></script>
|
||||
<script src="./perms.test.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -733,6 +737,49 @@
|
||||
</div>`);
|
||||
}
|
||||
|
||||
// Additional module suites, rendered data-driven so each new group
|
||||
// reuses the same template + handlers instead of a copy-pasted section.
|
||||
const extraGroups = [
|
||||
{ key: 'hosting', label: 'Hosting', tests: window.hostingTests || [] },
|
||||
{ key: 'apps', label: 'Apps', tests: window.appsTests || [] },
|
||||
{ key: 'os', label: 'OS', tests: window.osTests || [] },
|
||||
{ key: 'perms', label: 'Permissions', tests: window.permsTests || [] },
|
||||
];
|
||||
window.extraGroups = extraGroups;
|
||||
|
||||
for (const group of extraGroups) {
|
||||
$('#tests').append(`<h2><label><input type="checkbox" id="${group.key}Tests-group"> ${group.label}</label></h2>`);
|
||||
for (let i = 0; i < group.tests.length; i++) {
|
||||
const testInfo = getTestInfo(group.tests[i]);
|
||||
$('#tests').append(`<div class="test-container" id="${group.key}Tests-container-${i}">
|
||||
<div class="test-checkbox-container">
|
||||
<input type="checkbox" class="test-checkbox ${group.key}Tests-checkbox" id="${group.key}Tests${i}">
|
||||
<label for="${group.key}Tests${i}">
|
||||
<div class="test-name">${testInfo.name}</div>
|
||||
<div class="test-description">${testInfo.description}</div>
|
||||
</label><br>
|
||||
<button class="test-run-button" onclick="runSingleTest('${group.key}', ${i})">Run Test</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
// Same group-toggle + indeterminate wiring as the built-in groups.
|
||||
$(`#${group.key}Tests-group`).change(function() {
|
||||
$(`.${group.key}Tests-checkbox`).prop('checked', $(this).prop('checked'));
|
||||
});
|
||||
$(document).on('change', `.${group.key}Tests-checkbox`, function() {
|
||||
const total = $(`.${group.key}Tests-checkbox`).length;
|
||||
const checked = $(`.${group.key}Tests-checkbox:checked`).length;
|
||||
if (checked === 0) {
|
||||
$(`#${group.key}Tests-group`).prop('checked', false).prop('indeterminate', false);
|
||||
} else if (checked === total) {
|
||||
$(`#${group.key}Tests-group`).prop('checked', true).prop('indeterminate', false);
|
||||
} else {
|
||||
$(`#${group.key}Tests-group`).prop('checked', false).prop('indeterminate', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listeners for group checkboxes
|
||||
$('#fsTests-group').change(function() {
|
||||
const isChecked = $(this).prop('checked');
|
||||
@@ -858,7 +905,8 @@
|
||||
'txt2img': txt2imgTests,
|
||||
'txt2vid': txt2vidTests
|
||||
};
|
||||
|
||||
for (const g of (window.extraGroups || [])) testSuites[g.key] = g.tests;
|
||||
|
||||
const tests = testSuites[testType];
|
||||
const containerId = `${testType}Tests-container-${index}`;
|
||||
const buttonSelector = `#${containerId} .test-run-button`;
|
||||
@@ -1091,6 +1139,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Additional module suites (hosting/apps/os/perms), same loop shape.
|
||||
for (const group of (window.extraGroups || [])) {
|
||||
for (let i = 0; i < group.tests.length; i++) {
|
||||
if (document.getElementById(`${group.key}Tests${i}`)?.checked) {
|
||||
const testInfo = getTestInfo(group.tests[i]);
|
||||
testProgress.currentTest = `${group.label}: ${testInfo.name}`;
|
||||
updateProgressPanel();
|
||||
|
||||
try {
|
||||
await executeTest(group.tests[i]);
|
||||
$(`#${group.key}Tests-container-${i}`).css('background-color', '#85e085');
|
||||
testProgress.passed++;
|
||||
} catch (e) {
|
||||
console.error(`${group.label} Test failed:`, testInfo.name, e);
|
||||
$(`#${group.key}Tests-container-${i}`).css('background-color', '#ff8484');
|
||||
let errorMessage = e.message || e.toString();
|
||||
if (e.originalError) {
|
||||
errorMessage += '\n\nOriginal Error:\n' + JSON.stringify(e.originalError, null, 2);
|
||||
}
|
||||
$(`#${group.key}Tests-container-${i}`).append(`<pre style="color:red; white-space: pre-wrap; font-size: 12px; margin: 5px 0; padding: 10px; background-color: #f8f8f8; border-radius: 3px;">${errorMessage}</pre>`);
|
||||
testProgress.failed++;
|
||||
}
|
||||
|
||||
testProgress.completed++;
|
||||
updateProgressPanel();
|
||||
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show completion message
|
||||
testProgress.currentTest = `Complete! ${testProgress.passed} passed, ${testProgress.failed} failed`;
|
||||
updateProgressPanel();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable */
|
||||
// TODO: Make these more compatible with eslint
|
||||
// Hand-run in the browser harness. Read-only; safe to run against any account.
|
||||
window.osTests = [
|
||||
{
|
||||
name: "testUser",
|
||||
description: "os.user() returns the authenticated user with a username",
|
||||
test: async function() {
|
||||
try {
|
||||
const user = await puter.os.user();
|
||||
assert(user && typeof user === 'object', "user should be an object");
|
||||
assert(typeof user.username === 'string' && user.username.length > 0, "username should be a non-empty string");
|
||||
pass("testUser passed: " + user.username);
|
||||
} catch (error) {
|
||||
fail("testUser failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testUserCallback",
|
||||
description: "os.user() honors trailing success/error callbacks",
|
||||
test: async function() {
|
||||
try {
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
puter.os.user(resolve, reject);
|
||||
});
|
||||
assert(user && typeof user.username === 'string', "callback should deliver the user");
|
||||
pass("testUserCallback passed");
|
||||
} catch (error) {
|
||||
fail("testUserCallback failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testVersion",
|
||||
description: "os.version() returns a deployment version object",
|
||||
test: async function() {
|
||||
try {
|
||||
const version = await puter.os.version();
|
||||
assert(version && typeof version === 'object', "version should be an object");
|
||||
pass("testVersion passed: " + JSON.stringify(version));
|
||||
} catch (error) {
|
||||
fail("testVersion failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,103 @@
|
||||
/* eslint-disable */
|
||||
// TODO: Make these more compatible with eslint
|
||||
// Hand-run these ONE AT A TIME in the browser harness: most prompt the user
|
||||
// for permission, so they can't run unattended. They verify the request flows
|
||||
// resolve to a sane value; approve/deny the prompt to exercise both paths.
|
||||
window.permsTests = [
|
||||
{
|
||||
name: "testRequestEmail",
|
||||
description: "[interactive] requestEmail() returns an email, null, or undefined",
|
||||
test: async function() {
|
||||
try {
|
||||
const email = await puter.perms.requestEmail();
|
||||
assert(email === undefined || email === null || typeof email === 'string', "unexpected email value");
|
||||
pass("testRequestEmail passed: " + String(email));
|
||||
} catch (error) {
|
||||
fail("testRequestEmail failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testRequestReadApps",
|
||||
description: "[interactive] requestReadApps() resolves to a boolean",
|
||||
test: async function() {
|
||||
try {
|
||||
const granted = await puter.perms.requestReadApps();
|
||||
assert(typeof granted === 'boolean', "requestReadApps should resolve to a boolean");
|
||||
pass("testRequestReadApps passed: " + granted);
|
||||
} catch (error) {
|
||||
fail("testRequestReadApps failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testRequestReadDesktop",
|
||||
description: "[interactive] requestReadDesktop() returns the path or undefined",
|
||||
test: async function() {
|
||||
try {
|
||||
const path = await puter.perms.requestReadDesktop();
|
||||
assert(path === undefined || typeof path === 'string', "unexpected path value");
|
||||
pass("testRequestReadDesktop passed: " + String(path));
|
||||
} catch (error) {
|
||||
fail("testRequestReadDesktop failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testRequestWriteDesktop",
|
||||
description: "[interactive] requestWriteDesktop() returns the path or undefined",
|
||||
test: async function() {
|
||||
try {
|
||||
const path = await puter.perms.requestWriteDesktop();
|
||||
assert(path === undefined || typeof path === 'string', "unexpected path value");
|
||||
pass("testRequestWriteDesktop passed: " + String(path));
|
||||
} catch (error) {
|
||||
fail("testRequestWriteDesktop failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testRequestManageSubdomains",
|
||||
description: "[interactive] requestManageSubdomains() resolves to a boolean",
|
||||
test: async function() {
|
||||
try {
|
||||
const granted = await puter.perms.requestManageSubdomains();
|
||||
assert(typeof granted === 'boolean', "should resolve to a boolean");
|
||||
pass("testRequestManageSubdomains passed: " + granted);
|
||||
} catch (error) {
|
||||
fail("testRequestManageSubdomains failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testListGroups",
|
||||
description: "listGroups() returns a result without erroring",
|
||||
test: async function() {
|
||||
try {
|
||||
const result = await puter.perms.listGroups();
|
||||
assert(result && !result.error, "listGroups should not report an error: " + JSON.stringify(result));
|
||||
pass("testListGroups passed");
|
||||
} catch (error) {
|
||||
fail("testListGroups failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "testRequestWriteAppRootDirAccess",
|
||||
description: "[interactive] requestWriteAppRootDir(app) actually requests WRITE (bug fix). Set window.__testAppUid to an app uid first.",
|
||||
test: async function() {
|
||||
const appUid = window.__testAppUid;
|
||||
if (!appUid) {
|
||||
pass("testRequestWriteAppRootDirAccess skipped: set window.__testAppUid to an owned app uid to run");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await puter.perms.requestWriteAppRootDir(appUid);
|
||||
assert(result === undefined || typeof result === 'object', "unexpected result");
|
||||
pass("testRequestWriteAppRootDirAccess passed: " + JSON.stringify(result));
|
||||
} catch (error) {
|
||||
fail("testRequestWriteAppRootDirAccess failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -185,4 +185,44 @@ export default suite('apps', {
|
||||
'developer profile should be an object',
|
||||
);
|
||||
},
|
||||
|
||||
'create validates client-side with a backward-compatible error shape': async (t) => {
|
||||
let err: { code?: string; success?: boolean; error?: { code?: string; message?: string } } | undefined;
|
||||
try {
|
||||
await t.puter.apps.create({ indexURL: 'https://example.com/no-name' } as never);
|
||||
} catch (e) {
|
||||
err = e as typeof err;
|
||||
}
|
||||
// The backward-compatible data contract: top-level message/code plus
|
||||
// the legacy nested shape. (`instanceof Error` is covered in the
|
||||
// single-realm unit test — it isn't reliable across the prebuilt-bundle
|
||||
// boundary the browser fixture loads the SDK through.)
|
||||
t.assert.equal(typeof err?.message, 'string');
|
||||
t.assert.equal(err?.code, 'invalid_request');
|
||||
t.assert.equal(err?.success, false);
|
||||
t.assert.equal(err?.error?.code, 'invalid_request');
|
||||
t.assert.equal(err?.error?.message, 'Name is required');
|
||||
},
|
||||
|
||||
'create rejects a missing index URL before any network call': async (t) => {
|
||||
let err: { error?: { message?: string } } | undefined;
|
||||
try {
|
||||
await (t.puter.apps.create as (n: string) => Promise<unknown>)('apps-suite-name-only');
|
||||
} catch (e) {
|
||||
err = e as typeof err;
|
||||
}
|
||||
t.assert.equal(err?.error?.message, 'Index URL is required');
|
||||
},
|
||||
|
||||
'create remaps camelCase options to the stored app fields': async (t) => {
|
||||
await t.puter.apps.create({
|
||||
name: 'apps-suite-remap',
|
||||
indexURL: 'https://example.com/remap',
|
||||
filetypeAssociations: ['.txt', 'image/png'],
|
||||
maximizeOnStart: true,
|
||||
});
|
||||
const fetched = await t.puter.apps.get('apps-suite-remap');
|
||||
t.assert.deepEqual(fetched.filetype_associations, ['.txt', 'image/png']);
|
||||
t.assert.equal(Boolean(fetched.maximize_on_start), true);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -177,6 +177,17 @@ export default suite('hosting', {
|
||||
);
|
||||
},
|
||||
|
||||
'create accepts a full host and stores just the subdomain label': async (t) => {
|
||||
const dir = await makeSiteDir(t, 'fullhost');
|
||||
const created = await t.puter.hosting.create('hostingsuitefull.puter.site', dir);
|
||||
t.assert.equal(created.subdomain, 'hostingsuitefull');
|
||||
// Retrievable by the bare label and by the full host (both normalize).
|
||||
const byLabel = await t.puter.hosting.get('hostingsuitefull');
|
||||
t.assert.equal(byLabel.subdomain, 'hostingsuitefull');
|
||||
const byHost = await t.puter.hosting.get('hostingsuitefull.puter.com');
|
||||
t.assert.equal(byHost.subdomain, 'hostingsuitefull');
|
||||
},
|
||||
|
||||
'delete removes the subdomain': async (t) => {
|
||||
const dir = await makeSiteDir(t, 'delete');
|
||||
await t.puter.hosting.create('hosting-suite-delete', dir);
|
||||
|
||||
@@ -6,6 +6,7 @@ import fs from './fs.suite.ts';
|
||||
import hosting from './hosting.suite.ts';
|
||||
import kv from './kv.suite.ts';
|
||||
import net from './net.suite.ts';
|
||||
import os from './os.suite.ts';
|
||||
import perms from './perms.suite.ts';
|
||||
import system from './system.suite.ts';
|
||||
import util from './util.suite.ts';
|
||||
@@ -23,6 +24,7 @@ export const suites: Suite[] = [
|
||||
hosting,
|
||||
kv,
|
||||
net,
|
||||
os,
|
||||
perms,
|
||||
system,
|
||||
util,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { suite } from '../harness/types.ts';
|
||||
|
||||
export default suite('os', {
|
||||
'user returns the authenticated user': async (t) => {
|
||||
const user = await t.puter.os.user();
|
||||
t.assert.ok(user && typeof user === 'object', 'user should be an object');
|
||||
t.assert.equal(user.username, t.env.users.user.username);
|
||||
},
|
||||
|
||||
'user accepts trailing success/error callbacks': async (t) => {
|
||||
const user = await new Promise((resolve, reject) => {
|
||||
(t.puter.os.user as (s: (v: unknown) => void, e: (r: unknown) => void) => void)(
|
||||
resolve,
|
||||
reject,
|
||||
);
|
||||
});
|
||||
t.assert.equal(
|
||||
(user as { username: string }).username,
|
||||
t.env.users.user.username,
|
||||
);
|
||||
},
|
||||
|
||||
'version returns deployment version info': async (t) => {
|
||||
const version = await t.puter.os.version();
|
||||
t.assert.ok(
|
||||
version && typeof version === 'object',
|
||||
'version should be an object',
|
||||
);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user