chore: cleanup other puter-js modules to match new structure (#3440)

This commit is contained in:
Daniel Salazar
2026-07-24 17:42:06 -07:00
committed by GitHub
parent 6c4fa629a9
commit b8559c1221
46 changed files with 2251 additions and 1033 deletions
+4 -4
View File
@@ -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';
+81
View File
@@ -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');
}
}
+67
View File
@@ -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');
});
});
});
-308
View File
@@ -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;
-194
View File
@@ -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;
-96
View File
@@ -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;
-430
View File
@@ -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;
}
+60
View File
@@ -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);
}
+16
View File
@@ -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);
}
+31
View File
@@ -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();
});
}
+81
View File
@@ -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 },
});
+85
View File
@@ -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));
}
+26
View File
@@ -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);
}
+21
View File
@@ -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);
}
+77
View File
@@ -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;
+94
View File
@@ -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);
}
+67
View File
@@ -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;
+11
View File
@@ -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] };
+29
View File
@@ -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();
});
}
+21
View File
@@ -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',
});
});
});
+71
View File
@@ -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');
}
+118
View File
@@ -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 });
}
+46
View File
@@ -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');
}
+148
View File
@@ -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;
+35
View File
@@ -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` });
}
+130
View File
@@ -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);
}
}
},
];
+111
View File
@@ -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);
}
}
},
];
+80 -1
View File
@@ -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();
+47
View File
@@ -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);
}
}
},
];
+103
View File
@@ -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);
+2
View File
@@ -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,
+30
View File
@@ -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',
);
},
});